mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Make informational alerts a first-class severity
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -77,8 +77,10 @@ func alertSeveritySortRank(alert Alert) int {
|
||||
return 2
|
||||
case AlertLevelWarning:
|
||||
return 1
|
||||
default:
|
||||
case AlertLevelInfo:
|
||||
return 0
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user