Skip to content

feat(alerting): per-alert snooze and silent auto-resolve#2

Merged
sameerk27 merged 3 commits into
sameerk27:masterfrom
AnandSundar:feat/alert-snooze-auto-resolve
Jun 23, 2026
Merged

feat(alerting): per-alert snooze and silent auto-resolve#2
sameerk27 merged 3 commits into
sameerk27:masterfrom
AnandSundar:feat/alert-snooze-auto-resolve

Conversation

@AnandSundar

Copy link
Copy Markdown
Contributor

Addresses alert fatigue from the same alert firing every collection cycle. Adds two complementary levers:

Per-alert snooze — silence a specific instance for 4h, 24h, or 7d. Compositional with the existing per-policy SuppressionMinutes, which still blocks new alerts for a policy.

Silent auto-resolve — when an open alert's underlying metric has been below its threshold for AutoResolveDebounceCycles consecutive evaluation cycles, the alert transitions to auto_resolved. No notification is dispatched; no NotificationLog entry is written. Manual resolve still wins (first transition wins, terminal states cannot re-open).

Changes

  • src/M365SecurityDashboard.Api/Data/AlertingSchema.cs — four new IF COL_LENGTH ALTER blocks add SnoozedUntil, SnoozedBy, BelowThresholdStreakCount (NOT NULL DEFAULT 0), LastEvaluatedAt to TriggeredAlerts. Mirrors the existing SourceFailureDetails pattern, so installs created before this PR get the new columns at startup without a migration.
  • src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs — four new properties matching the schema.
  • src/M365SecurityDashboard.Api/Models/AlertingOptions.cs (new) — AutoResolveDebounceCycles (default 2).
  • src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs — auto-resolve loop appended after the dispatch block; injects IOptions<AlertingOptions>. Silent (no dispatch call, no log row).
  • src/M365SecurityDashboard.Api/Program.cs — two new endpoints (POST /api/triggered-alerts/{id}/snooze, POST .../unsnooze); SnoozeRequest DTO at the bottom; AlertingOptions binding alongside the existing GraphOptions binding.
  • src/M365SecurityDashboard.Api/appsettings.json — new Alerting:AutoResolveDebounceCycles section.
  • src/m365-security-dashboard-client/src/main.tsxTriggeredAlert interface gains the four new fields; acApi gains snooze / unsnooze; statusTone maps the new statuses (snoozed → neutral, auto_resolved → info); status filter dropdown adds two options; action cell gains a snooze <select> with 4h/24h/7d presets and an Unsnooze button; detail modal surfaces Snoozed until and Last evaluated; the active-alerts table and mini-list both render a muted "snoozed until" indicator.
  • src/M365SecurityDashboard.Api.Tests/ (new project) — 7 xUnit tests for the auto-resolve path. Uses Microsoft.EntityFrameworkCore.InMemory for the test AppDbContext. NotificationSender is constructed but never invoked by the loop under test, so no HTTP/DPAPI dependencies are exercised.

Testing

dotnet test M365SecurityAlertDashboard.sln
Passed!  - Failed: 0, Passed: 7, Skipped: 0, Total: 7

Coverage of the new auto-resolve logic:

  • streak increments on a below-threshold observation
  • auto-resolve after AutoResolveDebounceCycles consecutive observations
  • streak resets to zero on any above-threshold observation
  • debounce-of-one auto-resolves on the first observation
  • terminal-state alerts (resolved, auto_resolved) are skipped
  • no notification dispatch on auto-resolve (verified by an empty NotificationLogs table after transition)
  • AcknowledgedAt and AcknowledgedBy survive an auto-resolve transition

Deferred (not in this PR)

  • Maintenance windows / quiet hours
  • Per-policy snooze shortcut
  • "Snooze all" bulk action
  • Auto-resolve notification ("send resolved" mode)
  • Per-entity alerting (the biggest leverage move for noise reduction long-term, but a bigger refactor)
  • Real user identity for SnoozedBy (placeholder literal matches the existing AcknowledgedBy = "dashboard" pattern; auth lands separately)

Verification before merging

npm run build in src/m365-security-dashboard-client/ is clean. dotnet build is clean (0 warnings, 0 errors). Schema migration smoke test: start the API against an existing DB with pre-feature TriggeredAlerts rows; the four IF COL_LENGTH blocks run without error and existing rows render with the new fields as NULL or 0. Manual smoke for the snooze/unsnooze endpoints is in the plan notes.

Per-alert snooze (4h / 24h / 7d presets) plus silent auto-resolve on metric
recovery. Snooze data lives on TriggeredAlert and composes with the existing
per-policy SuppressionMinutes.

Schema: adds SnoozedUntil, SnoozedBy, BelowThresholdStreakCount, and
LastEvaluatedAt columns to TriggeredAlerts via idempotent IF COL_LENGTH
ALTER blocks, mirroring the existing SourceFailureDetails pattern.

API: POST /api/triggered-alerts/{id}/snooze accepts an absolute timestamp
or a duration in hours; POST .../unsnooze clears the state. Snoozing a
terminal-state alert returns 400.

Evaluator: after the existing dispatch loop, scans non-terminal alerts,
increments or resets a per-alert streak counter on each evaluation, and
transitions to Status="auto_resolved" after AutoResolveDebounceCycles
(default 2) consecutive below-threshold observations. Resolution is silent
- no notification dispatch, no NotificationLog write. Manual resolve
remains the user-driven terminal state; the first transition wins.

Config: new Alerting section in appsettings.json; bound via the existing
IOptions pattern.
Adds M365SecurityDashboard.Api.Tests with seven focused tests for the
new auto-resolve loop in AlertEvaluator:

- Streak increments on a below-threshold observation
- Auto-resolve after AutoResolveDebounceCycles consecutive observations
- Streak resets to zero on any above-threshold observation
- Debounce-of-one auto-resolves on the first observation
- Terminal-state alerts (resolved, auto_resolved) are skipped
- No notification dispatch on auto-resolve
- AcknowledgedAt and AcknowledgedBy survive an auto-resolve transition

Uses Microsoft.EntityFrameworkCore.InMemory for the test DbContext.
NotificationSender is constructed but never invoked by the loop under
test, so no HTTP/DPAPI dependencies are exercised.
Wires the Alert Center to the new per-alert snooze endpoints and renders
the new server-side fields (snoozedUntil, lastEvaluatedAt).

- TriggeredAlert interface extended with status union members
  ('snoozed' | 'auto_resolved') and the four new fields.
- acApi gains snooze(id, durationHours) and unsnooze(id).
- statusTone maps the new statuses to neutral (snoozed) and info
  (auto_resolved) tones so they read distinctly from new / acknowledged.
- Status filter dropdown gets two new options so users can isolate
  snoozed or auto-resolved rows.
- Action cell gains a snooze <select> with 4h / 24h / 7d presets and an
  Unsnooze button that appears when snoozedUntil is in the future.
- Detail modal surfaces Snoozed until and Last evaluated rows when
  populated.
- Mini-list and active-alerts table both render a muted 'snoozed until'
  indicator so the state is visible at a glance.
@sameerk27 sameerk27 self-assigned this Jun 23, 2026
@sameerk27 sameerk27 merged commit b47ed33 into sameerk27:master Jun 23, 2026
@sameerk27

Copy link
Copy Markdown
Owner

Really clean contribution — the IF COL_LENGTH migration pattern means existing installs just pick this up at startup with no manual steps, and the 7 xUnit tests covering the debounce edge cases (streak reset, terminal-state skip, silent dispatch, AcknowledgedAt preservation) are exactly the kind of coverage this codebase needed.

Made a few small fixes before merging: scoped the auto-resolve query to active policy IDs only to avoid loading orphaned rows every cycle, clamped durationHours to a 1-year max, and fixed a "Z until" copy artifact in the UI.

Great work — hope to see more PRs from you. 🙌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants