Make informational alerts a first-class severity

This commit is contained in:
Pulse Test
2026-08-28 00:45:10 +01:00
parent 6aab9c532b
commit 5ee1cdd002
54 changed files with 490 additions and 112 deletions
@@ -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
@@ -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
@@ -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
@@ -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.
+1 -1
View File
@@ -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<Alert[]> {
const queryParams = new URLSearchParams();
+1 -1
View File
@@ -42,7 +42,7 @@ export interface WebhookTemplate {
};
}
export type NotificationMinimumSeverity = 'all' | 'critical';
export type NotificationMinimumSeverity = 'all' | 'warning' | 'critical';
export interface EmailConfig {
enabled: boolean;
@@ -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}
>
<option value="all">{ALERT_DESTINATION_ALL_SEVERITIES_LABEL}</option>
{props.includeWarning !== false ? (
<option value="warning">{ALERT_DESTINATION_WARNING_AND_CRITICAL_LABEL}</option>
) : null}
<option value="critical">{ALERT_DESTINATION_CRITICAL_ONLY_LABEL}</option>
</FormSelect>
);
@@ -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;
@@ -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' : '';
@@ -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', () => {
@@ -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'),
},
],
},
];
@@ -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 || '',
@@ -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) {
</A>
<span class="text-xs text-muted">({alertTypeDisplayLabel(props.alert.type)})</span>
<Show when={!props.alert.acknowledged}>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
classList={{
'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300':
props.alert.level === 'critical',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300':
props.alert.level !== 'critical',
}}
>
{props.alert.level === 'critical' ? 'Critical' : 'Warning'}
<span class={getAlertSeverityBadgeClass(props.alert.level)}>
{formatAlertSeverityLabel(props.alert.level)}
</span>
</Show>
<Show when={props.alert.node}>
@@ -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();
@@ -47,7 +47,10 @@ export function AlertPushDestinationsSection(props: AlertPushDestinationsSection
<DestinationSeveritySelect
id="alert-push-minimum-severity"
value={props.minimumSeverity ?? 'all'}
onChange={(value) => props.onMinimumSeverityChange?.(value)}
includeWarning={false}
onChange={(value) =>
props.onMinimumSeverityChange?.(value === 'warning' ? 'all' : value)
}
help={ALERT_DESTINATIONS_PUSH_MINIMUM_SEVERITY_HELP}
/>
<a
@@ -133,4 +133,28 @@ describe('alertDestinationsModel', () => {
}),
);
});
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');
});
});
@@ -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);
@@ -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;
}
@@ -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;
@@ -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<EmailConfig> | 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),
};
};
+2 -2
View File
@@ -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 {
@@ -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();
@@ -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();
@@ -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<string, Alert> => {
return value as Record<string, Alert>;
};
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;
};
+2 -2
View File
@@ -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;
@@ -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',
);
});
});
@@ -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', () => {
@@ -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' }),
@@ -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) {
@@ -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(
@@ -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]
+21 -3
View File
@@ -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: '',
+1
View File
@@ -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") ||
+1
View File
@@ -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
+2
View File
@@ -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
}
+17
View File
@@ -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
+26
View File
@@ -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)
}
})
}
}
+5
View File
@@ -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)
}
+2
View File
@@ -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
}
+3 -1
View File
@@ -77,8 +77,10 @@ func alertSeveritySortRank(alert Alert) int {
return 2
case AlertLevelWarning:
return 1
default:
case AlertLevelInfo:
return 0
default:
return -1
}
}
+1 -4
View File
@@ -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()
+2
View File
@@ -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
}
+1 -1
View File
@@ -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 = ""
+26
View File
@@ -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)
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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)
@@ -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)
}
+52 -20
View File
@@ -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
</div>`, warning)
}
if info > 0 {
htmlBody += fmt.Sprintf(`
<div class="summary-item">
<div class="summary-count info-count">%d</div>
<div class="summary-label">Info</div>
</div>`, info)
}
htmlBody += fmt.Sprintf(`
</div>
</div>
@@ -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)))
}
@@ -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()
+13 -5
View File
@@ -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))
+6 -2
View File
@@ -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
}
+46 -4
View File
@@ -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 {
+6 -3
View File
@@ -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,
+4
View File
@@ -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")
}