From 5ee1cdd002c595b3b5837472c20310be330407ea Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Fri, 28 Aug 2026 00:45:10 +0100 Subject: [PATCH] Make informational alerts a first-class severity --- .../v6/internal/subsystems/alerts.md | 16 +++++ .../v6/internal/subsystems/api-contracts.md | 9 +++ .../v6/internal/subsystems/notifications.md | 14 ++++ .../v6/internal/subsystems/relay-runtime.md | 6 ++ frontend-modern/src/api/alerts.ts | 2 +- frontend-modern/src/api/notifications.ts | 2 +- .../Alerts/DestinationSeveritySelect.tsx | 12 +++- .../components/Workloads/guestRowModel.tsx | 2 +- .../components/Workloads/useGuestRowState.ts | 20 +++--- .../AlertAppriseDestinationsSection.test.tsx | 3 + .../alerts/AlertHistoryFiltersCard.tsx | 6 ++ .../alerts/AlertHistoryItemActions.tsx | 2 +- .../alerts/AlertOverviewAlertCard.tsx | 16 ++--- .../AlertPushDestinationsSection.test.tsx | 3 + .../alerts/AlertPushDestinationsSection.tsx | 5 +- .../__tests__/alertDestinationsModel.test.ts | 24 +++++++ .../__tests__/useAlertHistoryState.test.tsx | 21 +++++- .../features/alerts/alertDestinationsModel.ts | 13 ++-- .../src/features/alerts/alertHistoryModel.ts | 2 +- .../src/features/alerts/helpers.ts | 6 +- frontend-modern/src/features/alerts/types.ts | 4 +- .../features/alerts/useAlertHistoryState.ts | 2 +- .../features/alerts/useAlertOverviewState.ts | 3 +- .../storageBackups/storageAlertState.ts | 9 +-- frontend-modern/src/types/api.ts | 4 +- ...IncidentPresentation.branchcov0717.test.ts | 14 ++-- .../alertIncidentPresentation.test.ts | 5 +- .../src/utils/__tests__/alerts.test.ts | 13 ++++ .../utils/alertDestinationsPresentation.ts | 11 +-- .../src/utils/alertIncidentPresentation.ts | 9 +-- .../src/utils/alertOverviewPresentation.ts | 12 +++- frontend-modern/src/utils/alerts.ts | 24 ++++++- internal/alerts/active_persistence.go | 1 + internal/alerts/canonical_identity.go | 1 + internal/alerts/canonical_lifecycle.go | 2 + internal/alerts/config/types.go | 17 +++++ internal/alerts/config/types_test.go | 26 +++++++ internal/alerts/config_facade.go | 5 ++ internal/alerts/operational_contract.go | 2 + internal/alerts/read_model.go | 4 +- internal/alerts/system_alert.go | 5 +- internal/alerts/unified_incidents.go | 2 + internal/api/alerting/alerts.go | 2 +- internal/api/alerting/alerts_test.go | 26 +++++++ internal/mock/alert_history.go | 2 +- internal/mock/alert_incidents_test.go | 4 +- internal/mock/integration_coverage_test.go | 5 ++ internal/notifications/email_template.go | 72 +++++++++++++------ internal/notifications/email_template_test.go | 43 +++++++++++ internal/notifications/notifications.go | 18 +++-- internal/notifications/tag_routing.go | 8 ++- internal/notifications/tag_routing_test.go | 50 +++++++++++-- internal/relay/push.go | 9 ++- internal/relay/push_test.go | 4 ++ 54 files changed, 490 insertions(+), 112 deletions(-) create mode 100644 internal/alerts/config/types_test.go diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 57798f811..c0a429f33 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -610,6 +610,17 @@ result. These counts follow the shared Inventory totals visibility preference; the Period facet remains uncounted because it selects the fetched time scope rather than filtering the already-fetched rows. +Alert severity is one closed, end-to-end vocabulary: `info`, `warning`, and +`critical`. Alert creation, restored active state, history projection, +incidents, operational-trust projections, API responses, sorting, resource +highlighting, overview cards, badges, and history filters must preserve those +three meanings. The legacy history value `error` normalizes to `critical`; +empty or unknown runtime values fail safe to `warning` and must never become +low-urgency information accidentally. Informational alerts use the shared blue +presentation and sort below warnings, while the history facet exposes a real +Info option whose count and filtered rows use the same predicate as every +other severity. + Alert history row timestamps render clock time in the viewer's own locale and must carry the absolute date and time as a title. The date otherwise lives only in the day group header, which scrolls out of sight, and a hardcoded @@ -986,6 +997,11 @@ The same editor exposes a minimum-severity policy for email, Apprise, each webhook, and Relay mobile push. The alerts surface owns the coherent routing UX; notifications owns provider filtering and recovery receipts, while Relay owns privacy-safe mobile projection and persisted mobile routing policy. +Email and Apprise expose all three destination choices: all alerts, warnings +and critical alerts, or critical alerts only. Relay deliberately exposes its +smaller persisted policy of all alerts or critical alerts only; hiding the +warning option there is protocol honesty, not permission to collapse a stored +warning floor on notification-owned destinations. The alert manager callback layer now also has to stay fan-out-safe. Monitor delivery, the unified alert bridge, and Patrol-adjacent AI listeners must compose through additive fired/resolved subscriptions instead of overwriting a diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index d936254e7..9881e5633 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -72,6 +72,15 @@ The same live update applies all four `schedule.grouping` fields atomically. When grouping is disabled, pending and subsequent alerts are delivered individually; the API boundary must not reduce that setting to the window or grouping-key fields while silently ignoring `enabled`. +`GET /api/alerts/history` accepts the canonical severity facet values `info`, +`warning`, and `critical` (plus omitted/`all` for no severity filter) and +returns only matching rows after the time and resource filters. Alert payloads +use the same closed three-level vocabulary; legacy `error` data is normalized +to `critical` before it crosses the API boundary. Notification configuration +round-trips destination minimum severity as `all`, `warning`, or `critical`. +The Relay adapter has a deliberately narrower persisted floor of `all` or +`critical`, but an all-alert mobile payload still preserves canonical `info` +severity rather than rewriting it as warning. `POST /api/notifications/terminal-failures/retry` and `POST /api/notifications/terminal-failures/dismiss` are admin, `settings:write` operator actions. Their strict response is diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 37bb0e5c2..092c8e31f 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -417,6 +417,20 @@ minimum severity of `all`, `warning`, or `critical`. Omitted or unknown values preserve backwards-compatible all-alert delivery. Grouped firing alerts are filtered independently per destination, after both tag and severity policy are applied, so one incident batch may produce different destination payloads. +The meanings are exact: `all` accepts info, warning, and critical; `warning` +accepts warning and critical but excludes info; `critical` accepts only +critical. API normalization, frontend editing, and persisted configuration +must round-trip the warning floor rather than reducing it to all-alert +delivery. + +The delivered payload must preserve the selected alerts' canonical severity. +Single and grouped email subjects, summary counts, alert rows, and plain-text +fallbacks represent Info separately from Warning. ntfy derives the highest +severity in a group without promoting an all-info group: informational +delivery uses the `INFO` title prefix, default priority, and +`information_source` tag; warning and critical retain their higher attention +postures. Destination filtering is not complete if the final template relabels +or visually promotes the alert. Resolved delivery deliberately bypasses current tag and severity matching and is filtered by occurrence-bound delivery receipts instead. This guarantees that a diff --git a/docs/release-control/v6/internal/subsystems/relay-runtime.md b/docs/release-control/v6/internal/subsystems/relay-runtime.md index 67a170153..9907e0415 100644 --- a/docs/release-control/v6/internal/subsystems/relay-runtime.md +++ b/docs/release-control/v6/internal/subsystems/relay-runtime.md @@ -35,6 +35,12 @@ messages, metadata, and probe errors remain inside Pulse. The persisted Relay destination policy defaults to all alerts and may be set to critical-only; runtime dispatch reads the cached live policy rather than loading persisted configuration for each alert. +All-alert routing includes canonical informational alerts. Their mobile +projection preserves severity `info`, normal priority, and generic +informational lock-screen copy; it must not promote them to warning. Relay's +persisted minimum-severity vocabulary intentionally remains `all` or +`critical`, while warning and critical payloads retain their existing normal +and high attention postures respectively. Relay's independent whole-instance disconnect notification remains the sole dark-site signal and must not be conflated with this per-agent alert. diff --git a/frontend-modern/src/api/alerts.ts b/frontend-modern/src/api/alerts.ts index 1b7cf8d35..4548324ac 100644 --- a/frontend-modern/src/api/alerts.ts +++ b/frontend-modern/src/api/alerts.ts @@ -15,7 +15,7 @@ export class AlertsAPI { offset?: number; startTime?: string; endTime?: string; - severity?: 'warning' | 'critical' | 'all'; + severity?: 'info' | 'warning' | 'critical' | 'all'; resourceId?: string; }): Promise { const queryParams = new URLSearchParams(); diff --git a/frontend-modern/src/api/notifications.ts b/frontend-modern/src/api/notifications.ts index f1f431db9..74d3a5a0c 100644 --- a/frontend-modern/src/api/notifications.ts +++ b/frontend-modern/src/api/notifications.ts @@ -42,7 +42,7 @@ export interface WebhookTemplate { }; } -export type NotificationMinimumSeverity = 'all' | 'critical'; +export type NotificationMinimumSeverity = 'all' | 'warning' | 'critical'; export interface EmailConfig { enabled: boolean; diff --git a/frontend-modern/src/components/Alerts/DestinationSeveritySelect.tsx b/frontend-modern/src/components/Alerts/DestinationSeveritySelect.tsx index 6e2786908..d817aea57 100644 --- a/frontend-modern/src/components/Alerts/DestinationSeveritySelect.tsx +++ b/frontend-modern/src/components/Alerts/DestinationSeveritySelect.tsx @@ -6,6 +6,7 @@ import { ALERT_DESTINATION_CRITICAL_ONLY_LABEL, ALERT_DESTINATION_MINIMUM_SEVERITY_HELP, ALERT_DESTINATION_MINIMUM_SEVERITY_LABEL, + ALERT_DESTINATION_WARNING_AND_CRITICAL_LABEL, } from '@/utils/alertDestinationsPresentation'; interface DestinationSeveritySelectProps { @@ -14,6 +15,7 @@ interface DestinationSeveritySelectProps { onChange: (value: NotificationMinimumSeverity) => void; compact?: boolean; help?: string; + includeWarning?: boolean; } export function DestinationSeveritySelect(props: DestinationSeveritySelectProps) { @@ -23,13 +25,17 @@ export function DestinationSeveritySelect(props: DestinationSeveritySelectProps) label={ALERT_DESTINATION_MINIMUM_SEVERITY_LABEL} labelClass={props.compact ? undefined : 'text-xs uppercase tracking-[0.08em]'} value={props.value} - onChange={(event) => - props.onChange(event.currentTarget.value === 'critical' ? 'critical' : 'all') - } + onChange={(event) => { + const value = event.currentTarget.value; + props.onChange(value === 'critical' || value === 'warning' ? value : 'all'); + }} selectBaseClass={props.compact ? controlClass('px-2 py-1.5') : formControl} help={props.help ?? ALERT_DESTINATION_MINIMUM_SEVERITY_HELP} > + {props.includeWarning !== false ? ( + + ) : null} ); diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index 92c8192e0..d6a5c53f5 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -97,7 +97,7 @@ export interface GuestRowProps { badgeClass: string; hasAlert: boolean; alertCount: number; - severity: 'critical' | 'warning' | null; + severity: 'critical' | 'warning' | 'info' | null; hasPoweredOffAlert?: boolean; hasNonPoweredOffAlert?: boolean; hasUnacknowledgedAlert?: boolean; diff --git a/frontend-modern/src/components/Workloads/useGuestRowState.ts b/frontend-modern/src/components/Workloads/useGuestRowState.ts index 8021a5479..1e32f4196 100644 --- a/frontend-modern/src/components/Workloads/useGuestRowState.ts +++ b/frontend-modern/src/components/Workloads/useGuestRowState.ts @@ -242,13 +242,15 @@ export function useGuestRowState(props: GuestRowProps) { () => hasUnacknowledgedAlert() || hasAcknowledgedOnlyAlert(), ); - const alertAccentTone = createMemo<'critical' | 'warning' | 'acknowledged' | undefined>(() => { - if (!showAlertHighlight()) return undefined; - if (hasUnacknowledgedAlert()) { - return props.alertStyles?.severity === 'critical' ? 'critical' : 'warning'; - } - return 'acknowledged'; - }); + const alertAccentTone = createMemo<'critical' | 'warning' | 'info' | 'acknowledged' | undefined>( + () => { + if (!showAlertHighlight()) return undefined; + if (hasUnacknowledgedAlert()) { + return props.alertStyles?.severity ?? 'warning'; + } + return 'acknowledged'; + }, + ); const rowClass = createMemo(() => { const base = 'transition-all duration-200 relative group cursor-pointer'; @@ -261,7 +263,9 @@ export function useGuestRowState(props: GuestRowProps) { const alertBg = hasUnacknowledgedAlert() ? props.alertStyles?.severity === 'critical' ? 'bg-red-50 dark:bg-red-950' - : 'bg-yellow-50 dark:bg-yellow-950' + : props.alertStyles?.severity === 'info' + ? 'bg-blue-50 dark:bg-blue-950' + : 'bg-yellow-50 dark:bg-yellow-950' : ''; const defaultHover = hasUnacknowledgedAlert() ? '' : 'hover:bg-surface-hover'; const stoppedDimming = !isRunning() ? 'opacity-60' : ''; diff --git a/frontend-modern/src/features/alerts/AlertAppriseDestinationsSection.test.tsx b/frontend-modern/src/features/alerts/AlertAppriseDestinationsSection.test.tsx index 7df8b321b..e92fc9a4a 100644 --- a/frontend-modern/src/features/alerts/AlertAppriseDestinationsSection.test.tsx +++ b/frontend-modern/src/features/alerts/AlertAppriseDestinationsSection.test.tsx @@ -48,6 +48,9 @@ describe('AlertAppriseDestinationsSection', () => { expect(screen.getByRole('textbox', { name: 'Delivery targets' })).toBeInTheDocument(); expect(screen.getByRole('textbox', { name: 'CLI path' })).toBeInTheDocument(); expect(screen.getByRole('spinbutton', { name: 'Timeout (seconds)' })).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: 'Warnings and critical alerts' }), + ).toBeInTheDocument(); }); it('associates visible labels with HTTP mode fields', () => { diff --git a/frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx b/frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx index daf772bba..e1d627282 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryFiltersCard.tsx @@ -65,6 +65,12 @@ export function AlertHistoryFiltersCard(props: AlertHistoryFiltersCardProps) { tone: 'warning', count: severityCount('warning'), }, + { + value: 'info', + label: 'Info', + leading: filterChipStatusDot('bg-blue-500'), + count: severityCount('info'), + }, ], }, ]; diff --git a/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx b/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx index d0994de07..a49623eac 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx @@ -64,7 +64,7 @@ export function AlertHistoryItemActions(props: AlertHistoryItemActionsProps) { alert={{ id: props.alert.id, type: props.alert.rawAlertType || props.alert.title, - level: props.alert.severity as 'warning' | 'critical', + level: props.alert.severity as 'info' | 'warning' | 'critical', resourceId: props.alert.resourceId || '', resourceName: props.alert.resourceName, node: props.alert.node || '', diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx index 4b3890c4d..cf273fd75 100644 --- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx +++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx @@ -4,6 +4,10 @@ import { A } from '@solidjs/router'; import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton'; import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel'; import type { Alert } from '@/types/api'; +import { + formatAlertSeverityLabel, + getAlertSeverityBadgeClass, +} from '@/utils/alertSeverityPresentation'; import { getAlertOverviewAcknowledgedBadgeClass, getAlertOverviewAcknowledgedBadgeLabel, @@ -129,16 +133,8 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { ({alertTypeDisplayLabel(props.alert.type)}) - - {props.alert.level === 'critical' ? 'Critical' : 'Warning'} + + {formatAlertSeverityLabel(props.alert.level)} diff --git a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx index 4f279c12b..213db7211 100644 --- a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx +++ b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx @@ -49,6 +49,9 @@ describe('AlertPushDestinationsSection', () => { const severity = screen.getByRole('combobox', { name: 'Minimum alert severity' }); expect(severity).toHaveValue('critical'); + expect( + screen.queryByRole('option', { name: 'Warnings and critical alerts' }), + ).not.toBeInTheDocument(); fireEvent.change(severity, { target: { value: 'all' } }); expect(onMinimumSeverityChange).toHaveBeenCalledWith('all'); expect(screen.getByText(ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP)).toBeInTheDocument(); diff --git a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx index a69686362..a8fec92e3 100644 --- a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx +++ b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx @@ -47,7 +47,10 @@ export function AlertPushDestinationsSection(props: AlertPushDestinationsSection props.onMinimumSeverityChange?.(value)} + includeWarning={false} + onChange={(value) => + props.onMinimumSeverityChange?.(value === 'warning' ? 'all' : value) + } help={ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP} /> { }), ); }); + + it('preserves the warning floor through email and Apprise round trips', () => { + expect( + buildEmailConfigPayload({ + enabled: true, + provider: 'smtp', + server: 'smtp.internal', + port: 587, + username: '', + password: '', + from: 'pulse@example.com', + to: ['alerts@example.com'], + tls: true, + startTLS: true, + replyTo: '', + maxRetries: 3, + retryDelay: 60, + rateLimit: 0, + minimumSeverity: 'warning', + }).minimumSeverity, + ).toBe('warning'); + + expect(normalizeAppriseConfig({ minimumSeverity: 'warning' }).minimumSeverity).toBe('warning'); + }); }); diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx index a374ae5eb..bbc98a9a7 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertHistoryState.test.tsx @@ -175,6 +175,7 @@ describe('useAlertHistoryState', () => { makeEntry('alert-1', 'critical', 'db-01'), makeEntry('alert-2', 'warning', 'db-01'), makeEntry('alert-3', 'warning', 'web-01'), + makeEntry('alert-4', 'info', 'control-01'), ] as any); const { result } = renderHook(() => @@ -185,9 +186,10 @@ describe('useAlertHistoryState', () => { }), ); - await waitFor(() => expect(result.countForSeverity('all')).toBe(3)); + await waitFor(() => expect(result.countForSeverity('all')).toBe(4)); expect(result.countForSeverity('critical')).toBe(1); expect(result.countForSeverity('warning')).toBe(2); + expect(result.countForSeverity('info')).toBe(1); // Counts ignore the selected severity (each chip shows what its own // selection would render) but follow the search term. @@ -198,6 +200,23 @@ describe('useAlertHistoryState', () => { expect(result.countForSeverity('all')).toBe(2); }); + it('restores the informational severity filter from the canonical URL', async () => { + const [activeAlerts] = createSignal({}); + vi.mocked(AlertsAPI.getHistory).mockResolvedValue([] as any); + setMockLocation('?severity=info'); + + const { result } = renderHook(() => + useAlertHistoryState({ + activeAlerts, + getResource: () => undefined, + allResources: () => [], + }), + ); + + await waitFor(() => expect(AlertsAPI.getHistory).toHaveBeenCalledTimes(1)); + expect(result.severityFilter()).toBe('info'); + }); + it('clears search, period, and severity in one route write', async () => { const [activeAlerts] = createSignal({}); vi.mocked(AlertsAPI.getHistory).mockResolvedValue([] as any); diff --git a/frontend-modern/src/features/alerts/alertDestinationsModel.ts b/frontend-modern/src/features/alerts/alertDestinationsModel.ts index 0f7fc75d9..6e33c860a 100644 --- a/frontend-modern/src/features/alerts/alertDestinationsModel.ts +++ b/frontend-modern/src/features/alerts/alertDestinationsModel.ts @@ -1,6 +1,11 @@ import type { AppriseConfig, EmailConfig } from '@/api/notifications'; -import { formatAppriseTargets, normalizeEmailConfigFromAPI, parseAppriseTargets } from './helpers'; +import { + formatAppriseTargets, + normalizeEmailConfigFromAPI, + normalizeNotificationMinimumSeverity, + parseAppriseTargets, +} from './helpers'; import type { UIAppriseConfig, UIEmailConfig } from './types'; export function normalizeAppriseConfig( @@ -21,7 +26,7 @@ export function normalizeAppriseConfig( apiKeyHeader: config?.apiKeyHeader || 'X-API-KEY', skipTlsVerify: Boolean(config?.skipTlsVerify), hasApiKey: Boolean(config?.hasApiKey || config?.apiKey), - minimumSeverity: config?.minimumSeverity === 'critical' ? 'critical' : 'all', + minimumSeverity: normalizeNotificationMinimumSeverity(config?.minimumSeverity), }; } @@ -44,7 +49,7 @@ export function buildEmailConfigPayload(config: UIEmailConfig): EmailConfig { if (config.tagFilterMode !== undefined) { payload.tagFilterMode = config.tagFilterMode === 'any' ? 'any' : 'all'; } - payload.minimumSeverity = config.minimumSeverity === 'critical' ? 'critical' : 'all'; + payload.minimumSeverity = normalizeNotificationMinimumSeverity(config.minimumSeverity); return payload; } @@ -60,7 +65,7 @@ export function buildAppriseConfigPayload(config: UIAppriseConfig): AppriseConfi apiKey: config.apiKey, apiKeyHeader: config.apiKeyHeader, skipTlsVerify: config.skipTlsVerify, - minimumSeverity: config.minimumSeverity === 'critical' ? 'critical' : 'all', + minimumSeverity: normalizeNotificationMinimumSeverity(config.minimumSeverity), } as AppriseConfig; } diff --git a/frontend-modern/src/features/alerts/alertHistoryModel.ts b/frontend-modern/src/features/alerts/alertHistoryModel.ts index ea10cfed5..d22429154 100644 --- a/frontend-modern/src/features/alerts/alertHistoryModel.ts +++ b/frontend-modern/src/features/alerts/alertHistoryModel.ts @@ -6,7 +6,7 @@ import { alertTypeDisplayLabel, unifiedTypeToAlertDisplayType } from './helpers' export const MS_PER_HOUR = 60 * 60 * 1000; export type AlertHistoryRange = '24h' | '7d' | '30d' | 'all'; -export type AlertSeverityFilter = 'all' | 'warning' | 'critical'; +export type AlertSeverityFilter = 'all' | 'info' | 'warning' | 'critical'; export interface HistoryItem { id: string; diff --git a/frontend-modern/src/features/alerts/helpers.ts b/frontend-modern/src/features/alerts/helpers.ts index 22f62a0b7..40bed432e 100644 --- a/frontend-modern/src/features/alerts/helpers.ts +++ b/frontend-modern/src/features/alerts/helpers.ts @@ -130,6 +130,10 @@ export const readNumberValue = (value: unknown, fallback: number): number => export const readStringArrayValue = (value: unknown): string[] => Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; +export const normalizeNotificationMinimumSeverity = ( + value: unknown, +): 'all' | 'warning' | 'critical' => (value === 'critical' || value === 'warning' ? value : 'all'); + export const normalizeEmailConfigFromAPI = ( value: Partial | null | undefined, ): UIEmailConfig => { @@ -150,7 +154,7 @@ export const normalizeEmailConfigFromAPI = ( rateLimit: readNumberValue(value?.rateLimit, defaults.rateLimit), tagFilter: readStringArrayValue(value?.tagFilter), tagFilterMode: value?.tagFilterMode === 'any' ? 'any' : 'all', - minimumSeverity: value?.minimumSeverity === 'critical' ? 'critical' : 'all', + minimumSeverity: normalizeNotificationMinimumSeverity(value?.minimumSeverity), }; }; diff --git a/frontend-modern/src/features/alerts/types.ts b/frontend-modern/src/features/alerts/types.ts index 377b8d6d1..c51c6fca3 100644 --- a/frontend-modern/src/features/alerts/types.ts +++ b/frontend-modern/src/features/alerts/types.ts @@ -155,7 +155,7 @@ export interface UIEmailConfig { rateLimit: number; tagFilter?: string[]; tagFilterMode?: 'all' | 'any'; - minimumSeverity?: 'all' | 'critical'; + minimumSeverity?: 'all' | 'warning' | 'critical'; } export interface UIAppriseConfig { @@ -170,7 +170,7 @@ export interface UIAppriseConfig { apiKeyHeader: string; skipTlsVerify: boolean; hasApiKey: boolean; - minimumSeverity?: 'all' | 'critical'; + minimumSeverity?: 'all' | 'warning' | 'critical'; } export interface QuietHoursConfig { diff --git a/frontend-modern/src/features/alerts/useAlertHistoryState.ts b/frontend-modern/src/features/alerts/useAlertHistoryState.ts index 1afd4d7c2..76e3e021c 100644 --- a/frontend-modern/src/features/alerts/useAlertHistoryState.ts +++ b/frontend-modern/src/features/alerts/useAlertHistoryState.ts @@ -52,7 +52,7 @@ const parsePeriod = (raw: string | null | undefined): AlertHistoryRange => raw === '24h' || raw === '7d' || raw === '30d' || raw === 'all' ? raw : DEFAULT_TIME_FILTER; const parseSeverity = (raw: string | null | undefined): AlertSeverityFilter => - raw === 'warning' || raw === 'critical' ? raw : DEFAULT_SEVERITY_FILTER; + raw === 'info' || raw === 'warning' || raw === 'critical' ? raw : DEFAULT_SEVERITY_FILTER; export function useAlertHistoryState(props: UseAlertHistoryStateProps) { const location = useLocation(); diff --git a/frontend-modern/src/features/alerts/useAlertOverviewState.ts b/frontend-modern/src/features/alerts/useAlertOverviewState.ts index 23617a1d3..9dcfe453e 100644 --- a/frontend-modern/src/features/alerts/useAlertOverviewState.ts +++ b/frontend-modern/src/features/alerts/useAlertOverviewState.ts @@ -122,7 +122,8 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { if (a.acknowledged !== b.acknowledged) { return a.acknowledged ? 1 : -1; } - const severityRank = (level: string) => (level === 'critical' ? 0 : 1); + const severityRank = (level: string) => + level === 'critical' ? 0 : level === 'warning' ? 1 : 2; const severityDiff = severityRank(a.level) - severityRank(b.level); if (severityDiff !== 0) return severityDiff; const timeDiff = new Date(b.startTime).getTime() - new Date(a.startTime).getTime(); diff --git a/frontend-modern/src/features/storageBackups/storageAlertState.ts b/frontend-modern/src/features/storageBackups/storageAlertState.ts index d2af5afa9..b196daf4e 100644 --- a/frontend-modern/src/features/storageBackups/storageAlertState.ts +++ b/frontend-modern/src/features/storageBackups/storageAlertState.ts @@ -4,7 +4,7 @@ import type { StorageRecord } from './models'; export type StorageAlertRowState = { hasAlert: boolean; alertCount: number; - severity: 'critical' | 'warning' | null; + severity: 'critical' | 'warning' | 'info' | null; hasUnacknowledgedAlert: boolean; unacknowledgedCount: number; acknowledgedCount: number; @@ -37,9 +37,10 @@ export const asStorageAlertRecord = (value: unknown): Record => { return value as Record; }; -const severityWeight = (value: 'critical' | 'warning' | null): number => { - if (value === 'critical') return 2; - if (value === 'warning') return 1; +const severityWeight = (value: 'critical' | 'warning' | 'info' | null): number => { + if (value === 'critical') return 3; + if (value === 'warning') return 2; + if (value === 'info') return 1; return 0; }; diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 05eaaf61f..b1114daa9 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -1300,7 +1300,7 @@ export interface Stats { export interface Alert { id: string; type: string; - level: 'warning' | 'critical'; + level: 'info' | 'warning' | 'critical'; resourceId: string; canonicalSpecId?: string; canonicalKind?: string; @@ -1355,7 +1355,7 @@ export interface AlertDeliveryDiagnosis { reason: string; message: string; alertType: string; - level: 'warning' | 'critical'; + level: 'info' | 'warning' | 'critical'; resourceId?: string; resourceName?: string; node?: string; diff --git a/frontend-modern/src/utils/__tests__/alertIncidentPresentation.branchcov0717.test.ts b/frontend-modern/src/utils/__tests__/alertIncidentPresentation.branchcov0717.test.ts index b62a59db7..484c02975 100644 --- a/frontend-modern/src/utils/__tests__/alertIncidentPresentation.branchcov0717.test.ts +++ b/frontend-modern/src/utils/__tests__/alertIncidentPresentation.branchcov0717.test.ts @@ -140,31 +140,31 @@ describe('getAlertIncidentStatusPresentation — switch branch coverage', () => describe('getAlertIncidentLevelBadgeClass — branch coverage', () => { it('returns the critical palette for level === "critical"', () => { expect(getAlertIncidentLevelBadgeClass('critical')).toBe( - 'px-2 py-0.5 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300', + 'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300', ); }); - it('falls back to the warning palette for the canonical "warning" level', () => { + it('returns the warning palette for the canonical "warning" level', () => { expect(getAlertIncidentLevelBadgeClass('warning')).toBe( - 'px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300', + 'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', ); }); it('falls back to the warning palette for a null level', () => { expect(getAlertIncidentLevelBadgeClass(null)).toBe( - 'px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300', + 'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', ); }); it('falls back to the warning palette for an undefined level', () => { expect(getAlertIncidentLevelBadgeClass(undefined)).toBe( - 'px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300', + 'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', ); }); - it('falls back to the warning palette for any non-"critical" string', () => { + it('returns the informational palette for info', () => { expect(getAlertIncidentLevelBadgeClass('info')).toBe( - 'px-2 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300', + 'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', ); }); }); diff --git a/frontend-modern/src/utils/__tests__/alertIncidentPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertIncidentPresentation.test.ts index c4e5b1066..fdb8ba6fd 100644 --- a/frontend-modern/src/utils/__tests__/alertIncidentPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertIncidentPresentation.test.ts @@ -74,8 +74,9 @@ describe('alertIncidentPresentation', () => { it('returns canonical incident level badge classes', () => { expect(getAlertIncidentLevelBadgeClass('critical')).toContain('bg-red-100'); - expect(getAlertIncidentLevelBadgeClass('warning')).toContain('bg-yellow-100'); - expect(getAlertIncidentLevelBadgeClass(undefined)).toContain('bg-yellow-100'); + expect(getAlertIncidentLevelBadgeClass('warning')).toContain('bg-amber-100'); + expect(getAlertIncidentLevelBadgeClass('info')).toContain('bg-blue-100'); + expect(getAlertIncidentLevelBadgeClass(undefined)).toContain('bg-amber-100'); }); it('returns canonical alert history status presentation', () => { diff --git a/frontend-modern/src/utils/__tests__/alerts.test.ts b/frontend-modern/src/utils/__tests__/alerts.test.ts index 11896907d..3b882600a 100644 --- a/frontend-modern/src/utils/__tests__/alerts.test.ts +++ b/frontend-modern/src/utils/__tests__/alerts.test.ts @@ -85,6 +85,19 @@ describe('getAlertStyles', () => { }); }); + describe('informational alerts', () => { + it('uses a distinct low-urgency blue resource treatment', () => { + const alerts = createActiveAlerts(createAlert({ level: 'info' })); + const result = getAlertStyles('resource-1', alerts, true); + + expect(result.hasAlert).toBe(true); + expect(result.severity).toBe('info'); + expect(result.rowClass).toContain('bg-blue-50'); + expect(result.indicatorClass).toContain('bg-blue-500'); + expect(result.badgeClass).toContain('bg-blue-100'); + }); + }); + it('matches alert styles through any canonical resource identity candidate', () => { const alerts = createActiveAlerts( createAlert({ resourceId: 'provider-source-id', level: 'critical' }), diff --git a/frontend-modern/src/utils/alertDestinationsPresentation.ts b/frontend-modern/src/utils/alertDestinationsPresentation.ts index 411f785b2..cc706d682 100644 --- a/frontend-modern/src/utils/alertDestinationsPresentation.ts +++ b/frontend-modern/src/utils/alertDestinationsPresentation.ts @@ -9,8 +9,9 @@ export const ALERT_DESTINATIONS_ENABLED_LABEL = 'Enabled'; export const ALERT_DESTINATIONS_DISABLED_LABEL = 'Disabled'; export const ALERT_DESTINATION_MINIMUM_SEVERITY_LABEL = 'Minimum alert severity'; export const ALERT_DESTINATION_MINIMUM_SEVERITY_HELP = - 'Choose whether this destination receives every alert or only critical incidents. Recoveries follow the destination that received the original alert.'; + 'Choose whether this destination receives informational alerts, warnings and critical incidents, or critical incidents only. Recoveries follow the destination that received the original alert.'; export const ALERT_DESTINATION_ALL_SEVERITIES_LABEL = 'All alerts'; +export const ALERT_DESTINATION_WARNING_AND_CRITICAL_LABEL = 'Warnings and critical alerts'; export const ALERT_DESTINATION_CRITICAL_ONLY_LABEL = 'Critical alerts only'; export const ALERT_DESTINATIONS_EMAIL_PANEL_TITLE = 'Email notifications'; export const ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION = @@ -111,10 +112,10 @@ export function getAlertDestinationsStatusLabel(enabled: boolean) { return enabled ? ALERT_DESTINATIONS_ENABLED_LABEL : ALERT_DESTINATIONS_DISABLED_LABEL; } -export function getAlertDestinationSeverityLabel(minimumSeverity: 'all' | 'critical') { - return minimumSeverity === 'critical' - ? ALERT_DESTINATION_CRITICAL_ONLY_LABEL - : ALERT_DESTINATION_ALL_SEVERITIES_LABEL; +export function getAlertDestinationSeverityLabel(minimumSeverity: 'all' | 'warning' | 'critical') { + if (minimumSeverity === 'critical') return ALERT_DESTINATION_CRITICAL_ONLY_LABEL; + if (minimumSeverity === 'warning') return ALERT_DESTINATION_WARNING_AND_CRITICAL_LABEL; + return ALERT_DESTINATION_ALL_SEVERITIES_LABEL; } export function getAlertDestinationsAppriseTestLabel(isTesting: boolean) { diff --git a/frontend-modern/src/utils/alertIncidentPresentation.ts b/frontend-modern/src/utils/alertIncidentPresentation.ts index f767a5a40..b37a78feb 100644 --- a/frontend-modern/src/utils/alertIncidentPresentation.ts +++ b/frontend-modern/src/utils/alertIncidentPresentation.ts @@ -1,3 +1,5 @@ +import { getAlertSeverityBadgeClass } from '@/utils/alertSeverityPresentation'; + export type AlertIncidentLevel = 'warning' | 'critical' | string | null | undefined; export type AlertIncidentStatus = 'open' | 'acknowledged' | 'resolved' | 'unknown'; @@ -29,7 +31,6 @@ export const ALERT_RESOURCE_INCIDENT_NOTE_SAVE_FAILURE = 'Failed to save inciden export const ALERT_RESOURCE_INCIDENT_VIEW_TITLE = 'View incidents for this resource'; const ALERT_INCIDENT_STATUS_BASE = 'px-2 py-0.5 rounded'; -const ALERT_INCIDENT_LEVEL_BASE = 'px-2 py-0.5 rounded'; const ALERT_INCIDENT_EVENT_FILTER_BUTTON_BASE = 'px-2 py-0.5 rounded border text-[10px] transition-colors'; @@ -71,11 +72,7 @@ export function getAlertIncidentStatusPresentation( } export function getAlertIncidentLevelBadgeClass(level: AlertIncidentLevel): string { - if (level === 'critical') { - return `${ALERT_INCIDENT_LEVEL_BASE} bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300`; - } - - return `${ALERT_INCIDENT_LEVEL_BASE} bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300`; + return getAlertSeverityBadgeClass(String(level ?? 'warning')); } export function getAlertHistoryStatusPresentation( diff --git a/frontend-modern/src/utils/alertOverviewPresentation.ts b/frontend-modern/src/utils/alertOverviewPresentation.ts index 7bb1cb023..7d5abb080 100644 --- a/frontend-modern/src/utils/alertOverviewPresentation.ts +++ b/frontend-modern/src/utils/alertOverviewPresentation.ts @@ -379,16 +379,22 @@ export function getAlertOverviewCardPresentation( ? 'border-border bg-surface-alt' : level === 'critical' ? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900' - : 'border-yellow-300 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900'; + : level === 'warning' + ? 'border-yellow-300 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900' + : 'border-blue-300 dark:border-blue-800 bg-blue-50 dark:bg-blue-900'; const iconClassName = acknowledged ? 'mr-3 mt-0.5 transition-all text-green-600 dark:text-green-400' : level === 'critical' ? 'mr-3 mt-0.5 transition-all text-red-600 dark:text-red-400' - : 'mr-3 mt-0.5 transition-all text-yellow-600 dark:text-yellow-400'; + : level === 'warning' + ? 'mr-3 mt-0.5 transition-all text-yellow-600 dark:text-yellow-400' + : 'mr-3 mt-0.5 transition-all text-blue-600 dark:text-blue-400'; const resourceClassName = level === 'critical' ? 'text-sm font-medium truncate text-red-700 dark:text-red-400' - : 'text-sm font-medium truncate text-yellow-700 dark:text-yellow-400'; + : level === 'warning' + ? 'text-sm font-medium truncate text-yellow-700 dark:text-yellow-400' + : 'text-sm font-medium truncate text-blue-700 dark:text-blue-400'; return { cardClassName: ['border rounded-md p-3 sm:p-4 transition-all', opacityClass, stateClass] diff --git a/frontend-modern/src/utils/alerts.ts b/frontend-modern/src/utils/alerts.ts index f270d63d0..1f19790b5 100644 --- a/frontend-modern/src/utils/alerts.ts +++ b/frontend-modern/src/utils/alerts.ts @@ -7,7 +7,7 @@ const noAlertStyles = { badgeClass: '', hasAlert: false, alertCount: 0, - severity: null as 'critical' | 'warning' | null, + severity: null as 'critical' | 'warning' | 'info' | null, hasPoweredOffAlert: false, hasNonPoweredOffAlert: false, hasUnacknowledgedAlert: false, @@ -40,14 +40,15 @@ export const getAlertStyles = ( const unacknowledgedAlerts = alertsForResource.filter((alert) => !alert.acknowledged); const acknowledgedAlerts = alertsForResource.filter((alert) => alert.acknowledged); - let highestSeverity: 'critical' | 'warning' | null = null; + let highestSeverity: 'critical' | 'warning' | 'info' | null = null; let hasPoweredOffAlert = false; let hasNonPoweredOffAlert = false; unacknowledgedAlerts.forEach((alert) => { if ( alert.level === 'critical' || - (alert.level === 'warning' && highestSeverity !== 'critical') + (alert.level === 'warning' && highestSeverity !== 'critical') || + (alert.level === 'info' && highestSeverity === null) ) { highestSeverity = alert.level; } @@ -100,6 +101,23 @@ export const getAlertStyles = ( }; } + if (highestSeverity === 'info') { + return { + rowClass: 'bg-blue-50 dark:bg-blue-950 border-l-4 border-blue-500 dark:border-blue-400', + indicatorClass: 'bg-blue-500', + badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + hasAlert, + alertCount, + severity: 'info' as const, + hasPoweredOffAlert, + hasNonPoweredOffAlert, + hasUnacknowledgedAlert, + unacknowledgedCount, + acknowledgedCount, + hasAcknowledgedOnlyAlert: !hasUnacknowledgedAlert && acknowledgedCount > 0, + }; + } + return { rowClass: '', indicatorClass: '', diff --git a/internal/alerts/active_persistence.go b/internal/alerts/active_persistence.go index afc906cd6..1e1492713 100644 --- a/internal/alerts/active_persistence.go +++ b/internal/alerts/active_persistence.go @@ -251,6 +251,7 @@ func (m *Manager) restoreActiveAlertSnapshots(alerts []*Alert, source string, re if alert == nil { continue } + alert.Level = NormalizeAlertLevel(alert.Level) // Migrate legacy guest alert IDs (instance-node-VMID -> instance-VMID). isGuestAlert := strings.Contains(alert.Type, "cpu") || strings.Contains(alert.Type, "memory") || diff --git a/internal/alerts/canonical_identity.go b/internal/alerts/canonical_identity.go index 289061ce8..e02961a42 100644 --- a/internal/alerts/canonical_identity.go +++ b/internal/alerts/canonical_identity.go @@ -365,6 +365,7 @@ func cloneAlertForOutput(alert *Alert) *Alert { return nil } clone := alert.Clone() + clone.Level = NormalizeAlertLevel(clone.Level) backfillCanonicalIdentity(clone) publicID := exportedAlertID(clone, clone.ID) clone.ID = publicID diff --git a/internal/alerts/canonical_lifecycle.go b/internal/alerts/canonical_lifecycle.go index b978dd0f2..c50c99365 100644 --- a/internal/alerts/canonical_lifecycle.go +++ b/internal/alerts/canonical_lifecycle.go @@ -263,6 +263,8 @@ func canonicalAlertSeverity(level AlertLevel) alertspecs.AlertSeverity { switch level { case AlertLevelCritical: return alertspecs.AlertSeverityCritical + case AlertLevelInfo: + return alertspecs.AlertSeverityInfo default: return alertspecs.AlertSeverityWarning } diff --git a/internal/alerts/config/types.go b/internal/alerts/config/types.go index 763eecdce..934fcf3a8 100644 --- a/internal/alerts/config/types.go +++ b/internal/alerts/config/types.go @@ -10,10 +10,27 @@ import ( type AlertLevel string const ( + AlertLevelInfo AlertLevel = "info" AlertLevelWarning AlertLevel = "warning" AlertLevelCritical AlertLevel = "critical" ) +// NormalizeAlertLevel keeps the alert severity vocabulary stable at every +// persistence and API boundary. "error" is the only supported legacy alias: +// older mock/history payloads used it for connectivity failures, whose live +// alert equivalent is critical. Unknown values retain the historical warning +// fallback instead of accidentally becoming informational. +func NormalizeAlertLevel(level AlertLevel) AlertLevel { + switch strings.ToLower(strings.TrimSpace(string(level))) { + case string(AlertLevelInfo): + return AlertLevelInfo + case string(AlertLevelCritical), "error": + return AlertLevelCritical + default: + return AlertLevelWarning + } +} + // ActivationState represents the alert notification activation state type ActivationState string diff --git a/internal/alerts/config/types_test.go b/internal/alerts/config/types_test.go new file mode 100644 index 000000000..0ba804bde --- /dev/null +++ b/internal/alerts/config/types_test.go @@ -0,0 +1,26 @@ +package config + +import "testing" + +func TestNormalizeAlertLevelPreservesCanonicalVocabulary(t *testing.T) { + tests := []struct { + name string + in AlertLevel + want AlertLevel + }{ + {name: "info", in: " INFO ", want: AlertLevelInfo}, + {name: "warning", in: AlertLevelWarning, want: AlertLevelWarning}, + {name: "critical", in: AlertLevelCritical, want: AlertLevelCritical}, + {name: "legacy error", in: "error", want: AlertLevelCritical}, + {name: "empty fails safe", in: "", want: AlertLevelWarning}, + {name: "unknown fails safe", in: "unexpected", want: AlertLevelWarning}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := NormalizeAlertLevel(test.in); got != test.want { + t.Fatalf("NormalizeAlertLevel(%q) = %q, want %q", test.in, got, test.want) + } + }) + } +} diff --git a/internal/alerts/config_facade.go b/internal/alerts/config_facade.go index 5105175b6..d5352422e 100644 --- a/internal/alerts/config_facade.go +++ b/internal/alerts/config_facade.go @@ -33,6 +33,7 @@ type AlertIntentRule = alertconfig.AlertIntentRule type AlertIntentPolicyDocument = alertconfig.AlertIntentPolicyDocument const ( + AlertLevelInfo = alertconfig.AlertLevelInfo AlertLevelWarning = alertconfig.AlertLevelWarning AlertLevelCritical = alertconfig.AlertLevelCritical ActivationPending = alertconfig.ActivationPending @@ -101,6 +102,10 @@ func NormalizePoweredOffSeverity(level AlertLevel) AlertLevel { return alertconfig.NormalizePoweredOffSeverity(level) } +func NormalizeAlertLevel(level AlertLevel) AlertLevel { + return alertconfig.NormalizeAlertLevel(level) +} + func normalizePoweredOffSeverity(level AlertLevel) AlertLevel { return alertconfig.NormalizePoweredOffSeverity(level) } diff --git a/internal/alerts/operational_contract.go b/internal/alerts/operational_contract.go index 695e04c36..0923383a8 100644 --- a/internal/alerts/operational_contract.go +++ b/internal/alerts/operational_contract.go @@ -551,6 +551,8 @@ func operationalSeverityForAlert(alert *Alert) operationaltrust.OperationalSever return operationaltrust.SeverityCritical case AlertLevelWarning: return operationaltrust.SeverityWarning + case AlertLevelInfo: + return operationaltrust.SeverityInfo default: return operationaltrust.SeverityUnknown } diff --git a/internal/alerts/read_model.go b/internal/alerts/read_model.go index 526589586..a30630c5b 100644 --- a/internal/alerts/read_model.go +++ b/internal/alerts/read_model.go @@ -77,8 +77,10 @@ func alertSeveritySortRank(alert Alert) int { return 2 case AlertLevelWarning: return 1 - default: + case AlertLevelInfo: return 0 + default: + return -1 } } diff --git a/internal/alerts/system_alert.go b/internal/alerts/system_alert.go index bfa43bcda..98c3ce9b6 100644 --- a/internal/alerts/system_alert.go +++ b/internal/alerts/system_alert.go @@ -92,10 +92,7 @@ func (m *Manager) RaiseSystemAlert(input SystemAlertInput) bool { return false } alertID := SystemAlertID(alertType) - level := input.Level - if level == "" { - level = AlertLevelWarning - } + level := NormalizeAlertLevel(input.Level) m.mu.Lock() diff --git a/internal/alerts/unified_incidents.go b/internal/alerts/unified_incidents.go index 466ea84d6..7c48c6e08 100644 --- a/internal/alerts/unified_incidents.go +++ b/internal/alerts/unified_incidents.go @@ -426,6 +426,8 @@ func alertLevelFromCanonicalSeverity(level alertspecs.AlertSeverity) (AlertLevel return AlertLevelCritical, true case alertspecs.AlertSeverityWarning: return AlertLevelWarning, true + case alertspecs.AlertSeverityInfo: + return AlertLevelInfo, true default: return "", false } diff --git a/internal/api/alerting/alerts.go b/internal/api/alerting/alerts.go index f94b2fb42..18296631b 100644 --- a/internal/api/alerting/alerts.go +++ b/internal/api/alerting/alerts.go @@ -686,7 +686,7 @@ func (h *AlertHandlers) GetAlertHistory(w http.ResponseWriter, r *http.Request) switch severity { case "", "all": severity = "" - case "warning", "critical": + case "info", "warning", "critical": default: log.Warn().Str("severity", severity).Msg("Invalid severity filter, ignoring") severity = "" diff --git a/internal/api/alerting/alerts_test.go b/internal/api/alerting/alerts_test.go index 856859d2e..1c3df1f2f 100644 --- a/internal/api/alerting/alerts_test.go +++ b/internal/api/alerting/alerts_test.go @@ -584,6 +584,32 @@ func TestGetAlertHistory(t *testing.T) { assert.Len(t, resp, 1) } +func TestGetAlertHistoryFiltersInformationalSeverity(t *testing.T) { + mockMonitor := new(MockAlertMonitor) + mockManager := new(MockAlertManager) + mockMonitor.On("GetAlertManager").Return(mockManager) + h := NewAlertHandlers(nil, mockMonitor, nil) + + history := []alerts.Alert{ + {ID: "info", Level: alerts.AlertLevelInfo}, + {ID: "warning", Level: alerts.AlertLevelWarning}, + {ID: "critical", Level: alerts.AlertLevelCritical}, + } + mockManager.On("GetAlertHistory", 10).Return(history).Once() + + req := httptest.NewRequest(http.MethodGet, "/api/alerts/history?limit=10&severity=info", nil) + w := httptest.NewRecorder() + h.GetAlertHistory(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var response []alerts.Alert + assert.NoError(t, json.NewDecoder(w.Body).Decode(&response)) + if assert.Len(t, response, 1) { + assert.Equal(t, "info", response[0].ID) + } + mockManager.AssertExpectations(t) +} + func TestClearAlertHistory(t *testing.T) { mockMonitor := new(MockAlertMonitor) mockManager := new(MockAlertManager) diff --git a/internal/mock/alert_history.go b/internal/mock/alert_history.go index 931f61ff1..1da92380d 100644 --- a/internal/mock/alert_history.go +++ b/internal/mock/alert_history.go @@ -43,7 +43,7 @@ func buildAlertHistoryAt(nodes []models.Node, vms []models.VM, containers []mode }, { alertType: "connectivity", - level: "error", + level: "critical", messages: []string{ "Node offline", "Connection lost", diff --git a/internal/mock/alert_incidents_test.go b/internal/mock/alert_incidents_test.go index 1e718088e..9d92d5f58 100644 --- a/internal/mock/alert_incidents_test.go +++ b/internal/mock/alert_incidents_test.go @@ -25,7 +25,7 @@ func TestBuildAlertIncidentFixturesCreatesCompleteLifecycleData(t *testing.T) { { ID: "active-complete-lifecycle", Type: "connectivity", - Level: "error", + Level: "critical", ResourceID: "node-1", ResourceName: "pve-1", StartTime: now.Add(-90 * time.Second), @@ -80,7 +80,7 @@ func TestMockAlertIncidentQueriesAndNotesShareCanonicalFixture(t *testing.T) { now := time.Date(2026, time.August, 27, 12, 0, 0, 0, time.UTC) history := []models.Alert{ {ID: "hist-resource-new", Type: "threshold", Level: "warning", ResourceID: "vm-101", StartTime: now.Add(-10 * time.Minute)}, - {ID: "hist-resource-old", Type: "backup", Level: "error", ResourceID: "vm-101", StartTime: now.Add(-2 * time.Hour)}, + {ID: "hist-resource-old", Type: "backup", Level: "critical", ResourceID: "vm-101", StartTime: now.Add(-2 * time.Hour)}, } enriched, incidents := buildAlertIncidentFixtures(history, now) diff --git a/internal/mock/integration_coverage_test.go b/internal/mock/integration_coverage_test.go index 6f5ce10a6..78d86354a 100644 --- a/internal/mock/integration_coverage_test.go +++ b/internal/mock/integration_coverage_test.go @@ -556,6 +556,11 @@ func TestMockAlertHistoryMatchesProductionLifecycleContract(t *testing.T) { t.Fatal("mock history is empty") } for index, alert := range history { + switch alert.Level { + case "info", "warning", "critical": + default: + t.Fatalf("mock history row %q has non-canonical severity %q", alert.ID, alert.Level) + } if strings.HasPrefix(alert.ID, "active-") { t.Fatalf("mock history contains synthetic open row %q", alert.ID) } diff --git a/internal/notifications/email_template.go b/internal/notifications/email_template.go index 9d7ef40ad..c7cadeb97 100644 --- a/internal/notifications/email_template.go +++ b/internal/notifications/email_template.go @@ -75,9 +75,14 @@ func EmailTemplate(alertList []*alerts.Alert, isSingle bool) (subject, htmlBody, } func singleAlertTemplate(alert *alerts.Alert) (subject, htmlBody, textBody string) { - levelColor := "#ff6b6b" - levelBg := "#fee" - if alert.Level == "warning" { + level := alerts.NormalizeAlertLevel(alert.Level) + levelColor := "#3b82f6" + levelBg := "#eff6ff" + switch level { + case alerts.AlertLevelCritical: + levelColor = "#ff6b6b" + levelBg = "#fee" + case alerts.AlertLevelWarning: levelColor = "#ffd93d" levelBg = "#fffaeb" } @@ -85,9 +90,9 @@ func singleAlertTemplate(alert *alerts.Alert) (subject, htmlBody, textBody strin alertType := alertTypeDisplay(alert.Type) subject = fmt.Sprintf("[Pulse Alert] %s: %s on %s", - titleCase(string(alert.Level)), alertType, alert.ResourceName) + titleCase(string(level)), alertType, alert.ResourceName) - escapedLevel := html.EscapeString(string(alert.Level)) + escapedLevel := html.EscapeString(string(level)) escapedResourceName := html.EscapeString(alert.ResourceName) escapedMessage := html.EscapeString(alert.Message) escapedCurrentValue := html.EscapeString(formatMetricValue(alert.Type, alert.Value)) @@ -230,7 +235,7 @@ Details: This is an automated notification from Pulse Monitoring. View alerts and configure settings in your Pulse dashboard.`, - strings.ToUpper(string(alert.Level)), + strings.ToUpper(string(level)), alert.ResourceName, alert.ResourceName, alert.ResourceID, @@ -261,7 +266,7 @@ Details: This is an automated notification from Pulse Monitoring. View alerts and configure settings in your Pulse dashboard.`, - strings.ToUpper(string(alert.Level)), + strings.ToUpper(string(level)), alert.ResourceName, alert.ResourceName, alert.ResourceID, @@ -281,39 +286,53 @@ View alerts and configure settings in your Pulse dashboard.`, func groupedAlertTemplate(alertList []*alerts.Alert) (subject, htmlBody, textBody string) { critical := 0 warning := 0 + info := 0 patrolFindings := 0 for _, alert := range alertList { - if alert.Level == "critical" { + switch alerts.NormalizeAlertLevel(alert.Level) { + case alerts.AlertLevelCritical: critical++ - } else { + case alerts.AlertLevelWarning: warning++ + case alerts.AlertLevelInfo: + info++ } if isPatrolFindingAlert(alert) { patrolFindings++ } } - // Subject line - if critical > 0 && warning > 0 { - subject = fmt.Sprintf("[Pulse Alert] %d Critical, %d Warning alerts", critical, warning) - } else if critical > 0 { - subject = fmt.Sprintf("[Pulse Alert] %d Critical alert%s", critical, pluralize(critical)) - } else { - subject = fmt.Sprintf("[Pulse Alert] %d Warning alert%s", warning, pluralize(warning)) + // Subject line preserves every severity represented in the group. Treating + // informational alerts as warnings here would undo destination severity + // routing at the final user-visible boundary. + severityParts := make([]string, 0, 3) + if critical > 0 { + severityParts = append(severityParts, fmt.Sprintf("%d Critical", critical)) } + if warning > 0 { + severityParts = append(severityParts, fmt.Sprintf("%d Warning", warning)) + } + if info > 0 { + severityParts = append(severityParts, fmt.Sprintf("%d Info", info)) + } + subject = fmt.Sprintf("[Pulse Alert] %s alert%s", strings.Join(severityParts, ", "), pluralize(len(alertList))) // Build alert rows var alertRows strings.Builder for _, alert := range alertList { - levelColor := "#ff6b6b" - if alert.Level == "warning" { + level := alerts.NormalizeAlertLevel(alert.Level) + levelColor := "#3b82f6" + switch level { + case alerts.AlertLevelCritical: + levelColor = "#ff6b6b" + case alerts.AlertLevelWarning: levelColor = "#ffd93d" } escapedResourceName := html.EscapeString(alert.ResourceName) escapedType := html.EscapeString(alert.Type) escapedNode := html.EscapeString(alertNodeDisplay(alert)) - escapedLevel := html.EscapeString(string(alert.Level)) + escapedLevel := html.EscapeString(string(level)) escapedValue := html.EscapeString(formatMetricValue(alert.Type, alert.Value)) escapedThreshold := html.EscapeString(formatMetricThreshold(alert.Type, alert.Threshold)) escapedDuration := html.EscapeString(formatDuration(time.Since(alert.StartTime))) @@ -385,6 +404,7 @@ func groupedAlertTemplate(alertList []*alerts.Alert) (subject, htmlBody, textBod .summary-count { font-size: 32px; font-weight: 500; } .critical-count { color: #ff6b6b; } .warning-count { color: #ffd93d; } + .info-count { color: #3b82f6; } .summary-label { color: #666; font-size: 14px; margin-top: 5px; } .alerts-table { width: 100%%; margin-top: 20px; border-collapse: collapse; } .alerts-table th { text-align: left; padding: 12px; border-bottom: 2px solid #e9ecef; color: #666; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; } @@ -427,6 +447,14 @@ func groupedAlertTemplate(alertList []*alerts.Alert) (subject, htmlBody, textBod `, warning) } + if info > 0 { + htmlBody += fmt.Sprintf(` +
+
%d
+
Info
+
`, info) + } + htmlBody += fmt.Sprintf(` @@ -462,12 +490,16 @@ func groupedAlertTemplate(alertList []*alerts.Alert) (subject, htmlBody, textBod if warning > 0 { textBuilder.WriteString(fmt.Sprintf("Warning: %d\n", warning)) } + if info > 0 { + textBuilder.WriteString(fmt.Sprintf("Info: %d\n", info)) + } textBuilder.WriteString("\nAlert Details:\n") textBuilder.WriteString("─────────────────────────────────────────────────────────────\n") for i, alert := range alertList { + level := alerts.NormalizeAlertLevel(alert.Level) textBuilder.WriteString(fmt.Sprintf("\n%d. %s (%s)\n", i+1, alert.ResourceName, alert.ResourceID)) - textBuilder.WriteString(fmt.Sprintf(" Level: %s | Type: %s\n", strings.ToUpper(string(alert.Level)), alert.Type)) + textBuilder.WriteString(fmt.Sprintf(" Level: %s | Type: %s\n", strings.ToUpper(string(level)), alert.Type)) if !isPatrolFindingAlert(alert) { textBuilder.WriteString(fmt.Sprintf(" Value: %s (Threshold: %s)\n", formatMetricValue(alert.Type, alert.Value), formatMetricThreshold(alert.Type, alert.Threshold))) } diff --git a/internal/notifications/email_template_test.go b/internal/notifications/email_template_test.go index 17df28dc4..a4d0c27c9 100644 --- a/internal/notifications/email_template_test.go +++ b/internal/notifications/email_template_test.go @@ -606,6 +606,21 @@ func TestEmailTemplate(t *testing.T) { expectSingleSubject: true, subjectContains: "Warning", }, + { + name: "informational alert remains informational", + alerts: []*alerts.Alert{ + { + ID: "alert-info", + Level: alerts.AlertLevelInfo, + Type: "system_update", + ResourceName: "pulse-host", + StartTime: time.Now(), + }, + }, + isSingle: true, + expectSingleSubject: true, + subjectContains: "Info", + }, { name: "multiple critical alerts only uses grouped template", alerts: []*alerts.Alert{ @@ -705,6 +720,34 @@ func TestEmailTemplate(t *testing.T) { } } +func TestGroupedEmailTemplatePreservesInformationalSeverity(t *testing.T) { + t.Parallel() + + alertList := []*alerts.Alert{ + {ID: "info", Level: alerts.AlertLevelInfo, ResourceName: "host", Type: "system_update", StartTime: time.Now()}, + {ID: "warning", Level: alerts.AlertLevelWarning, ResourceName: "vm", Type: "cpu", StartTime: time.Now()}, + } + subject, htmlBody, textBody := EmailTemplate(alertList, false) + + if !strings.Contains(subject, "1 Warning, 1 Info alerts") { + t.Fatalf("subject = %q, want both represented severities", subject) + } + if !strings.Contains(htmlBody, ">Info<") || !strings.Contains(textBody, "Info: 1") { + t.Fatalf("grouped template did not expose informational summary: subject=%q text=%q", subject, textBody) + } +} + +func TestEmailTemplateFailsUnknownSeveritySafeToWarning(t *testing.T) { + t.Parallel() + + alert := &alerts.Alert{ID: "unknown", Level: "unexpected", ResourceName: "host", Type: "system", StartTime: time.Now()} + subject, htmlBody, textBody := EmailTemplate([]*alerts.Alert{alert}, true) + + if !strings.Contains(subject, "Warning") || !strings.Contains(htmlBody, "warning Alert") || !strings.Contains(textBody, "WARNING ALERT") { + t.Fatalf("unknown severity did not fail safe to warning: subject=%q text=%q", subject, textBody) + } +} + func TestEmailTemplateEscapesHTMLContent(t *testing.T) { t.Parallel() diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index a0498ed54..79d494a22 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -2514,7 +2514,7 @@ func withNtfyAlertHeaders(webhook WebhookConfig, alertList []*alerts.Alert) Webh webhook.Headers = make(map[string]string) } - level := alerts.AlertLevelWarning + level := alerts.AlertLevelInfo var primary *alerts.Alert for _, alert := range alertList { if alert == nil { @@ -2523,19 +2523,27 @@ func withNtfyAlertHeaders(webhook WebhookConfig, alertList []*alerts.Alert) Webh if primary == nil { primary = alert } - if alert.Level == alerts.AlertLevelCritical { + alertLevel := alerts.NormalizeAlertLevel(alert.Level) + if alertLevel == alerts.AlertLevelCritical { level = alerts.AlertLevelCritical break } + if alertLevel == alerts.AlertLevelWarning { + level = alerts.AlertLevelWarning + } } - levelLabel := "WARNING" - priority := "high" - severityTag := "warning" + levelLabel := "INFO" + priority := "default" + severityTag := "information_source" if level == alerts.AlertLevelCritical { levelLabel = "CRITICAL" priority = "urgent" severityTag = "rotating_light" + } else if level == alerts.AlertLevelWarning { + levelLabel = "WARNING" + priority = "high" + severityTag = "warning" } titleSubject := fmt.Sprintf("%d alerts", len(alertList)) diff --git a/internal/notifications/tag_routing.go b/internal/notifications/tag_routing.go index c99a2c13c..5de65affe 100644 --- a/internal/notifications/tag_routing.go +++ b/internal/notifications/tag_routing.go @@ -126,13 +126,17 @@ func notificationAlertMatchesTags(alert *alerts.Alert, filter []string, mode str } func notificationAlertMeetsMinimumSeverity(alert *alerts.Alert, minimumSeverity string) bool { + if alert == nil { + return false + } + level := alerts.NormalizeAlertLevel(alert.Level) switch normalizeNotificationMinimumSeverity(minimumSeverity) { case notificationMinimumSeverityAll: return true case notificationMinimumSeverityWarning: - return alert != nil && (alert.Level == alerts.AlertLevelWarning || alert.Level == alerts.AlertLevelCritical) + return level == alerts.AlertLevelWarning || level == alerts.AlertLevelCritical case notificationMinimumSeverityCritical: - return alert != nil && alert.Level == alerts.AlertLevelCritical + return level == alerts.AlertLevelCritical default: return false } diff --git a/internal/notifications/tag_routing_test.go b/internal/notifications/tag_routing_test.go index 5337d86d6..8596d266e 100644 --- a/internal/notifications/tag_routing_test.go +++ b/internal/notifications/tag_routing_test.go @@ -95,6 +95,7 @@ func TestResolvedTagRoutingDefersToDeliveryReceipts(t *testing.T) { } func TestBuildNotificationDeliveryJobsRoutesGroupedAlertsByDestinationSeverity(t *testing.T) { + info := &alerts.Alert{ID: "info", Level: alerts.AlertLevelInfo, StartTime: time.Unix(1_699_999_999, 0)} warning := &alerts.Alert{ID: "warning", Level: alerts.AlertLevelWarning, StartTime: time.Unix(1_700_000_000, 0)} critical := &alerts.Alert{ID: "critical", Level: alerts.AlertLevelCritical, StartTime: time.Unix(1_700_000_001, 0)} @@ -102,18 +103,19 @@ func TestBuildNotificationDeliveryJobsRoutesGroupedAlertsByDestinationSeverity(t EmailConfig{Enabled: true, MinimumSeverity: notificationMinimumSeverityCritical}, []WebhookConfig{ {ID: "all", Enabled: true, MinimumSeverity: notificationMinimumSeverityAll}, + {ID: "warning", Enabled: true, MinimumSeverity: notificationMinimumSeverityWarning}, {ID: "critical", Enabled: true, MinimumSeverity: notificationMinimumSeverityCritical}, }, AppriseConfig{Enabled: true, Mode: AppriseModeHTTP, ServerURL: "https://apprise.example.test", MinimumSeverity: notificationMinimumSeverityCritical}, - []*alerts.Alert{warning, critical}, + []*alerts.Alert{info, warning, critical}, eventAlert, time.Time{}, ) - if len(jobs) != 4 { - t.Fatalf("jobs = %d, want email, two webhooks, and Apprise", len(jobs)) + if len(jobs) != 5 { + t.Fatalf("jobs = %d, want email, three webhooks, and Apprise", len(jobs)) } - want := [][]string{{"critical"}, {"warning", "critical"}, {"critical"}, {"critical"}} + want := [][]string{{"critical"}, {"info", "warning", "critical"}, {"warning", "critical"}, {"critical"}, {"critical"}} for index, expected := range want { if got := alertIDs(jobs[index].Alerts); !reflect.DeepEqual(got, expected) { t.Fatalf("job %d alerts = %v, want %v", index, got, expected) @@ -171,6 +173,9 @@ func TestResolvedSeverityRoutingDefersToOccurrenceReceipts(t *testing.T) { } func TestNotificationMinimumSeverityNormalizationAndMatching(t *testing.T) { + if got := normalizeNotificationMinimumSeverity(" WARNING "); got != notificationMinimumSeverityWarning { + t.Fatalf("normalized severity = %q, want warning", got) + } if got := normalizeNotificationMinimumSeverity(" CRITICAL "); got != notificationMinimumSeverityCritical { t.Fatalf("normalized severity = %q, want critical", got) } @@ -183,6 +188,24 @@ func TestNotificationMinimumSeverityNormalizationAndMatching(t *testing.T) { if !notificationAlertMeetsMinimumSeverity(&alerts.Alert{Level: alerts.AlertLevelCritical}, notificationMinimumSeverityCritical) { t.Fatal("critical alert did not meet critical-only floor") } + if !notificationAlertMeetsMinimumSeverity(&alerts.Alert{Level: alerts.AlertLevelInfo}, notificationMinimumSeverityAll) { + t.Fatal("all-alert floor excluded an informational alert") + } + if notificationAlertMeetsMinimumSeverity(&alerts.Alert{Level: alerts.AlertLevelInfo}, notificationMinimumSeverityWarning) { + t.Fatal("warning floor included an informational alert") + } + if !notificationAlertMeetsMinimumSeverity(&alerts.Alert{Level: alerts.AlertLevelWarning}, notificationMinimumSeverityWarning) { + t.Fatal("warning floor excluded a warning alert") + } + if !notificationAlertMeetsMinimumSeverity(&alerts.Alert{Level: alerts.AlertLevelCritical}, notificationMinimumSeverityWarning) { + t.Fatal("warning floor excluded a critical alert") + } + if !notificationAlertMeetsMinimumSeverity(&alerts.Alert{Level: "error"}, notificationMinimumSeverityCritical) { + t.Fatal("critical floor excluded the supported legacy error alias") + } + if notificationAlertMeetsMinimumSeverity(&alerts.Alert{Level: "unexpected"}, notificationMinimumSeverityCritical) { + t.Fatal("unknown alert severity was promoted to critical") + } } func TestNotificationTagConfigNormalizationAndCopies(t *testing.T) { @@ -226,6 +249,25 @@ func TestNotificationTagConfigNormalizationAndCopies(t *testing.T) { } } +func TestNtfyHeadersPreserveInformationalSeverity(t *testing.T) { + webhook := withNtfyAlertHeaders(WebhookConfig{}, []*alerts.Alert{{ + ID: "info", + Level: alerts.AlertLevelInfo, + ResourceName: "pulse-host", + Type: "system_update", + }}) + + if got := webhook.Headers["Title"]; got != "INFO: pulse-host" { + t.Fatalf("ntfy title = %q, want informational title", got) + } + if got := webhook.Headers["Priority"]; got != "default" { + t.Fatalf("ntfy priority = %q, want default", got) + } + if got := webhook.Headers["Tags"]; got != "information_source,pulse,system_update" { + t.Fatalf("ntfy tags = %q, want informational routing tags", got) + } +} + func alertIDs(alertList []*alerts.Alert) []string { ids := make([]string, 0, len(alertList)) for _, alert := range alertList { diff --git a/internal/relay/push.go b/internal/relay/push.go index 90388e58c..ce3e43e34 100644 --- a/internal/relay/push.go +++ b/internal/relay/push.go @@ -108,16 +108,19 @@ func NewExternalProbeUnavailableNotification(alertID string) PushNotificationPay // owning incident surface and fetch current detail. func NewAlertFiredNotification(alertID, severity string) PushNotificationPayload { severity = strings.ToLower(strings.TrimSpace(severity)) - if severity != AlertMinimumSeverityCritical { + if severity != "info" && severity != AlertMinimumSeverityCritical { severity = "warning" } priority := PushPriorityNormal - title := "Pulse warning" - body := "Pulse detected a warning alert. Open Pulse for details." + title := "Pulse information" + body := "Pulse recorded an informational alert. Open Pulse for details." if severity == AlertMinimumSeverityCritical { priority = PushPriorityHigh title = "Critical Pulse alert" body = "Pulse detected a critical alert. Open Pulse for details." + } else if severity == "warning" { + title = "Pulse warning" + body = "Pulse detected a warning alert. Open Pulse for details." } return PushNotificationPayload{ Type: PushTypeAlertFired, diff --git a/internal/relay/push_test.go b/internal/relay/push_test.go index 617bfdd24..6dc6d861f 100644 --- a/internal/relay/push_test.go +++ b/internal/relay/push_test.go @@ -128,6 +128,7 @@ func TestNewAlertFiredNotification(t *testing.T) { title string }{ {name: "warning", severity: "warning", priority: PushPriorityNormal, title: "Pulse warning"}, + {name: "info", severity: "info", priority: PushPriorityNormal, title: "Pulse information"}, {name: "critical", severity: "critical", priority: PushPriorityHigh, title: "Critical Pulse alert"}, } { t.Run(tc.name, func(t *testing.T) { @@ -149,6 +150,9 @@ func TestNewAlertFiredNotification(t *testing.T) { } func TestAlertMinimumSeverityRouting(t *testing.T) { + if !AlertMeetsMinimumSeverity("info", AlertMinimumSeverityAll) { + t.Fatal("all-alert policy excluded an informational alert") + } if !AlertMeetsMinimumSeverity("warning", AlertMinimumSeverityAll) { t.Fatal("all-alert policy excluded a warning") }