diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7ffcf6ffdb..2e4a1ee2b6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -549,6 +549,40 @@ This tolerance delays activation; it does not disable powered-off monitoring. Use the existing guest offline-alert toggle when a guest should never produce powered-off alerts. +### Acknowledge and snooze existing alerts + +In **Alerts → Overview**, these actions apply to an existing incident, not its +threshold rule. Monitoring continues; neither action repairs the workload or +confirms recovery. + +| Action | Effect | +| --- | --- | +| **Acknowledge** | Marks the incident as seen. While acknowledged, further firing notifications, escalation and its recovery notification are suppressed. The underlying alert can remain active. | +| **Unacknowledge** | Removes that acknowledgement. Normal notification policies apply again; this is not a guaranteed immediate resend. | +| **Snooze** | Pauses notifications and escalation for this incident until the selected time, including critical notifications. Monitoring can still detect recovery during the snooze. | +| **Resume** | Ends this incident's snooze early. It does not remove an acknowledgement or clear the underlying alert. | + +Acknowledged alerts are hidden from the default active list and excluded from +its **Active** count. Use **Show acknowledged** to inspect them and +**Unacknowledge** only when they need attention again. **No unacknowledged +alerts** does not mean every workload recovered. Before **Acknowledge all**, read +the count and scope: the toolbar applies to all unacknowledged active alerts; +a group's button applies to that group, including its collapsed related alerts. +Use the individual action when only one incident has been reviewed. + +Choose a **Snooze** duration on the incident's card and check its **Snoozed until** +time. **Until tomorrow at 9:00** uses the browser's local timezone, not the +[quiet-hours timezone](#quiet-hours-and-notification-holds). On expiry or +**Resume**, an acknowledged incident remains acknowledged; remove that hold +separately if intended. Other schedules, routing, cooldowns and destination +settings still apply, so ending a snooze is not proof of delivery or a promise +to replay missed notifications. + +These incident actions are not **Dismiss retained failures** or **Retry retained +deliveries**. They do not retry a failed notification. Check fresh readings and +the workload for recovery, and [Recent delivery activity](TROUBLESHOOTING.md#test-succeeds-but-real-alerts-are-missing) +for delivery. Do not create an outage or lower thresholds to test an action. + ### Quiet hours and notification holds Open **Alerts → Schedule → Quiet hours** to enable a notification quiet period. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 6151fbb6c0..d8067ab893 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -623,8 +623,10 @@ alert was generated, routed or delivered to the intended recipient. Turn delivery on there only when you intend to send alerts. - Check the affected alert, the destination's **Enabled** state, minimum alert severity and tag filters. Review the [quiet-hours schedule](CONFIGURATION.md#quiet-hours-and-notification-holds) - and any mute, acknowledgement or maintenance policy before treating an absent - attempt as a transport failure. + and [acknowledgement or snooze](CONFIGURATION.md#acknowledge-and-snooze-existing-alerts), + plus any mute or maintenance policy, before treating an absent attempt as a + transport failure. In **Alerts → Overview**, use **Show acknowledged** to find + hidden incidents; **Resume** ends a snooze but does not remove acknowledgement. - Use **Recent delivery activity** to correlate the original alert, destination and absolute timestamp, including held-notification reasons. An empty window is not proof of healthy delivery; an **unavailable** read is not an empty log. diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 7ffcf6ffdb..2e4a1ee2b6 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -549,6 +549,40 @@ This tolerance delays activation; it does not disable powered-off monitoring. Use the existing guest offline-alert toggle when a guest should never produce powered-off alerts. +### Acknowledge and snooze existing alerts + +In **Alerts → Overview**, these actions apply to an existing incident, not its +threshold rule. Monitoring continues; neither action repairs the workload or +confirms recovery. + +| Action | Effect | +| --- | --- | +| **Acknowledge** | Marks the incident as seen. While acknowledged, further firing notifications, escalation and its recovery notification are suppressed. The underlying alert can remain active. | +| **Unacknowledge** | Removes that acknowledgement. Normal notification policies apply again; this is not a guaranteed immediate resend. | +| **Snooze** | Pauses notifications and escalation for this incident until the selected time, including critical notifications. Monitoring can still detect recovery during the snooze. | +| **Resume** | Ends this incident's snooze early. It does not remove an acknowledgement or clear the underlying alert. | + +Acknowledged alerts are hidden from the default active list and excluded from +its **Active** count. Use **Show acknowledged** to inspect them and +**Unacknowledge** only when they need attention again. **No unacknowledged +alerts** does not mean every workload recovered. Before **Acknowledge all**, read +the count and scope: the toolbar applies to all unacknowledged active alerts; +a group's button applies to that group, including its collapsed related alerts. +Use the individual action when only one incident has been reviewed. + +Choose a **Snooze** duration on the incident's card and check its **Snoozed until** +time. **Until tomorrow at 9:00** uses the browser's local timezone, not the +[quiet-hours timezone](#quiet-hours-and-notification-holds). On expiry or +**Resume**, an acknowledged incident remains acknowledged; remove that hold +separately if intended. Other schedules, routing, cooldowns and destination +settings still apply, so ending a snooze is not proof of delivery or a promise +to replay missed notifications. + +These incident actions are not **Dismiss retained failures** or **Retry retained +deliveries**. They do not retry a failed notification. Check fresh readings and +the workload for recovery, and [Recent delivery activity](TROUBLESHOOTING.md#test-succeeds-but-real-alerts-are-missing) +for delivery. Do not create an outage or lower thresholds to test an action. + ### Quiet hours and notification holds Open **Alerts → Schedule → Quiet hours** to enable a notification quiet period. diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index 6151fbb6c0..d8067ab893 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -623,8 +623,10 @@ alert was generated, routed or delivered to the intended recipient. Turn delivery on there only when you intend to send alerts. - Check the affected alert, the destination's **Enabled** state, minimum alert severity and tag filters. Review the [quiet-hours schedule](CONFIGURATION.md#quiet-hours-and-notification-holds) - and any mute, acknowledgement or maintenance policy before treating an absent - attempt as a transport failure. + and [acknowledgement or snooze](CONFIGURATION.md#acknowledge-and-snooze-existing-alerts), + plus any mute or maintenance policy, before treating an absent attempt as a + transport failure. In **Alerts → Overview**, use **Show acknowledged** to find + hidden incidents; **Resume** ends a snooze but does not remove acknowledgement. - Use **Recent delivery activity** to correlate the original alert, destination and absolute timestamp, including held-notification reasons. An empty window is not proof of healthy delivery; an **unavailable** read is not an empty log. diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx index 8ee5e8e9fa..e86164adaf 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx @@ -147,7 +147,7 @@ describe('useAlertOverviewState', () => { acknowledged: makeAlert('acknowledged', new Date(now - 2 * 60_000).toISOString(), true), old: makeAlert('old', new Date(now - 3 * 86_400_000).toISOString(), false), }); - const [showAcknowledged] = createSignal(false); + const [showAcknowledged, setShowAcknowledged] = createSignal(false); const updateAlert = vi.fn(); vi.mocked(AlertsAPI.acknowledge).mockResolvedValue(undefined as any); @@ -185,6 +185,17 @@ describe('useAlertOverviewState', () => { ); expect(notificationStore.success).toHaveBeenCalledWith('Alert acknowledged'); expect(result.processingAlerts().has('warning')).toBe(true); + // Hiding/counting acknowledgement is not deletion or measured recovery. + expect(result.alertStats()).toMatchObject({ active: 1, acknowledged: 2 }); + expect(result.filteredAlerts().map((alert) => alert.id)).toEqual(['old']); + expect(Object.keys(activeAlerts())).toHaveLength(3); + setShowAcknowledged(true); + expect(result.filteredAlerts().map((alert) => alert.id)).toEqual([ + 'old', + 'warning', + 'acknowledged', + ]); + setShowAcknowledged(false); vi.advanceTimersByTime(1500); expect(result.processingAlerts().has('warning')).toBe(false); diff --git a/frontend-modern/src/utils/__tests__/alertIncidentActionsHelp.test.ts b/frontend-modern/src/utils/__tests__/alertIncidentActionsHelp.test.ts new file mode 100644 index 0000000000..d6f1ae6e30 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertIncidentActionsHelp.test.ts @@ -0,0 +1,113 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { renderDocMarkdown } from '@/features/docs/docMarkdown'; +import { snoozePresetExpiry } from '@/features/alerts/AlertSnoozeAction'; +import { + getAlertOverviewAcknowledgedToggleLabel, + getAlertOverviewPrimaryActionLabel, + getAlertOverviewResumeLabel, + getAlertOverviewSnoozeOptionLabel, +} from '@/utils/alertOverviewPresentation'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const read = (name: string): string => readFileSync(path.join(root, name), 'utf8'); + +function article(name: string) { + const element = document.createElement('article'); + element.innerHTML = renderDocMarkdown(read(`docs/${name}.md`), name); + return element; +} + +function actions() { + const source = read('docs/CONFIGURATION.md') + .split('### Acknowledge and snooze existing alerts')[1] + ?.split('### Quiet hours and notification holds')[0]; + expect(source, 'existing incident controls need an explanation of their holds').toBeDefined(); + const element = document.createElement('article'); + element.innerHTML = renderDocMarkdown(source!, 'CONFIGURATION'); + return element; +} + +describe('existing alert incident actions help', () => { + it('renders the actual control names and keeps notification holds separate from recovery', () => { + const rows = [...actions().querySelectorAll('tbody tr')].map((row) => + row.textContent?.replace(/\s+/g, ' '), + ); + expect(rows).toHaveLength(4); + expect(rows[0]).toContain( + getAlertOverviewPrimaryActionLabel({ acknowledged: false, processing: false }), + ); + expect(rows[0]).toContain('firing notifications, escalation and its recovery notification'); + expect(rows[0]).toContain('underlying alert can remain active'); + expect(rows[1]).toContain( + getAlertOverviewPrimaryActionLabel({ acknowledged: true, processing: false }), + ); + expect(rows[1]).toContain('not a guaranteed immediate resend'); + expect(rows[2]).toContain('including critical notifications'); + expect(rows[3]).toContain(getAlertOverviewResumeLabel()); + expect(rows[3]).toContain('does not remove an acknowledgement'); + }); + + it('explains hidden incidents and bulk scope rather than an all-clear', () => { + const text = actions().textContent!.replace(/\s+/g, ' '); + expect(text).toContain('neither action repairs the workload or confirms recovery'); + expect(text).toContain( + 'hidden from the default active list and excluded from its Active count', + ); + expect(text).toContain(getAlertOverviewAcknowledgedToggleLabel(false)); + expect(text).toContain('does not mean every workload recovered'); + expect(text).toContain('toolbar applies to all unacknowledged active alerts'); + expect(text).toContain('including its collapsed related alerts'); + expect(text).toContain('Use the individual action when only one incident has been reviewed'); + }); + + it('binds tomorrow morning to the real local-calendar preset and preserves separate holds', () => { + const text = actions().textContent!.replace(/\s+/g, ' '); + expect(text).toContain(getAlertOverviewSnoozeOptionLabel('tomorrowMorning')); + expect(text).toContain("browser's local timezone, not the quiet-hours timezone"); + // Deliberately use local constructors: the preset uses the browser's date, + // not a UTC duration or the alert schedule's independently configured zone. + const expiry = snoozePresetExpiry('tomorrowMorning', new Date(2026, 9, 7, 22, 30)); + expect(expiry.getFullYear()).toBe(2026); + expect(expiry.getMonth()).toBe(9); + expect(expiry.getDate()).toBe(8); + expect(expiry.getHours()).toBe(9); + expect(expiry.getMinutes()).toBe(0); + expect(text).toContain('an acknowledged incident remains acknowledged'); + expect(text).toContain('not proof of delivery or a promise to replay missed notifications'); + expect(text).toContain('They do not retry a failed notification'); + expect(text).toContain('Do not create an outage or lower thresholds'); + }); + + it('ships exact guidance with working diagnosis and schedule links', () => { + for (const name of ['CONFIGURATION', 'TROUBLESHOOTING']) { + expect(read(`frontend-modern/public/docs/${name}.md`)).toBe(read(`docs/${name}.md`)); + } + for (const [from, to, fragment, label] of [ + [ + 'TROUBLESHOOTING', + 'CONFIGURATION', + 'acknowledge-and-snooze-existing-alerts', + 'acknowledgement or snooze', + ], + [ + 'CONFIGURATION', + 'TROUBLESHOOTING', + 'test-succeeds-but-real-alerts-are-missing', + 'Recent delivery activity', + ], + ]) { + const link = article(from).querySelector(`a[href="/docs/${to}#${fragment}"]`); + expect(link?.textContent).toBe(label); + expect(link?.hasAttribute('data-doc-link')).toBe(true); + expect(article(to).querySelector(`[id="${fragment}"]`)).not.toBeNull(); + } + expect(actions().querySelector('a[href="#quiet-hours-and-notification-holds"]')).not.toBeNull(); + expect( + article('CONFIGURATION').querySelector('#quiet-hours-and-notification-holds'), + ).not.toBeNull(); + }); +});