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

feat(alerting): per-alert snooze and silent auto-resolve
This commit is contained in:
sameerk27
2026-06-23 10:16:24 +05:30
committed by GitHub
11 changed files with 579 additions and 8 deletions
+12 -1
View File
@@ -1,9 +1,13 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "M365SecurityDashboard.Api", "src\M365SecurityDashboard.Api\M365SecurityDashboard.Api.csproj", "{A0AD3D97-983E-4665-B8A0-BB72D07686B6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8CEF94CD-3FDE-42A2-B93C-4D9552702532}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "M365SecurityDashboard.Api.Tests", "src\M365SecurityDashboard.Api.Tests\M365SecurityDashboard.Api.Tests.csproj", "{B8504228-F65D-4D8B-B972-B6471E615FB9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -14,5 +18,12 @@ Global
{A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0AD3D97-983E-4665-B8A0-BB72D07686B6}.Release|Any CPU.Build.0 = Release|Any CPU
{B8504228-F65D-4D8B-B972-B6471E615FB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8504228-F65D-4D8B-B972-B6471E615FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8504228-F65D-4D8B-B972-B6471E615FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8504228-F65D-4D8B-B972-B6471E615FB9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{B8504228-F65D-4D8B-B972-B6471E615FB9} = {8CEF94CD-3FDE-42A2-B93C-4D9552702532}
EndGlobalSection
EndGlobal
@@ -0,0 +1,349 @@
using M365SecurityDashboard.Api.Data;
using M365SecurityDashboard.Api.Models;
using M365SecurityDashboard.Api.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace M365SecurityDashboard.Api.Tests;
/// <summary>
/// Focused tests for the auto-resolve path in <see cref="AlertEvaluator"/>.
/// Covers streak increment / reset, transition to <c>auto_resolved</c>,
/// terminal-state skip, debounce-of-one, no notification dispatch on
/// resolution, and preservation of user-set fields.
/// </summary>
public class AlertEvaluatorAutoResolveTests
{
private const string RiskyUsersMetric = "riskyUsersCount";
private const int Threshold = 3;
private static AlertEvaluator BuildEvaluator(AppDbContext db, int autoResolveDebounceCycles = 2)
{
var options = Microsoft.Extensions.Options.Options.Create(new AlertingOptions
{
AutoResolveDebounceCycles = autoResolveDebounceCycles,
});
// The auto-resolve loop never calls DispatchAsync; we still need a
// real NotificationSender because AlertEvaluator takes it in its
// primary constructor. All channels are disabled below, so any
// accidental dispatch attempt writes no NotificationLog rows.
var sender = new NotificationSender(
new NullHttpClientFactory(),
new SecretProtector(NullLogger<SecretProtector>.Instance),
NullLogger<NotificationSender>.Instance);
return new AlertEvaluator(
db,
sender,
options,
NullLogger<AlertEvaluator>.Instance);
}
private static AlertPolicy RiskyUsersPolicy(int threshold = Threshold) => new()
{
Id = Guid.NewGuid(),
Name = "Risky Users",
Enabled = true,
Category = "identity",
Metric = RiskyUsersMetric,
Threshold = threshold,
Severity = "high",
Condition = "Risky users ≥ 1",
SuppressionMinutes = 60,
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
};
private static void SeedOpenRiskyUsers(AppDbContext db, int count)
{
for (var i = 0; i < count; i++)
{
db.SecurityAlerts.Add(new SecurityAlert
{
AlertType = "RiskyUser",
Severity = AlertSeverity.High,
Service = M365ServiceArea.EntraId,
Title = $"risky-{i}",
DetectedAt = DateTimeOffset.UtcNow.AddHours(-i),
IsResolved = false,
});
}
db.SaveChanges();
}
[Fact]
public async Task Streak_IncrementsOnBelowThresholdObservation()
{
using var db = TestAppDbContextFactory.Create();
var policy = RiskyUsersPolicy();
db.AlertPolicies.Add(policy);
db.TriggeredAlerts.Add(new TriggeredAlert
{
Id = Guid.NewGuid(),
PolicyId = policy.Id,
PolicyName = policy.Name,
Severity = policy.Severity,
Category = policy.Category,
Condition = policy.Condition,
MetricValue = 5,
Threshold = policy.Threshold,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
Status = "new",
});
await db.SaveChangesAsync();
SeedOpenRiskyUsers(db, count: 0); // metric = 0, below threshold
var evaluator = BuildEvaluator(db);
await evaluator.EvaluateAsync(CancellationToken.None);
var alert = await db.TriggeredAlerts.SingleAsync();
Assert.Equal(1, alert.BelowThresholdStreakCount);
Assert.Equal("new", alert.Status);
Assert.NotNull(alert.LastEvaluatedAt);
}
[Fact]
public async Task AutoResolve_AfterNConsecutiveBelow()
{
using var db = TestAppDbContextFactory.Create();
var policy = RiskyUsersPolicy();
db.AlertPolicies.Add(policy);
db.TriggeredAlerts.Add(new TriggeredAlert
{
Id = Guid.NewGuid(),
PolicyId = policy.Id,
PolicyName = policy.Name,
Severity = policy.Severity,
Category = policy.Category,
Condition = policy.Condition,
MetricValue = 5,
Threshold = policy.Threshold,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
Status = "new",
});
await db.SaveChangesAsync();
SeedOpenRiskyUsers(db, count: 0); // metric = 0, below threshold
var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 2);
// First evaluation: streak goes from 0 to 1, status stays "new".
await evaluator.EvaluateAsync(CancellationToken.None);
var afterFirst = await db.TriggeredAlerts.SingleAsync();
Assert.Equal(1, afterFirst.BelowThresholdStreakCount);
Assert.Equal("new", afterFirst.Status);
// Second consecutive below-threshold evaluation: streak hits 2, auto-resolves.
await evaluator.EvaluateAsync(CancellationToken.None);
var afterSecond = await db.TriggeredAlerts.SingleAsync();
Assert.Equal("auto_resolved", afterSecond.Status);
Assert.Equal(2, afterSecond.BelowThresholdStreakCount);
// Resolution is silent: no NotificationLog rows for this alert.
Assert.Empty(await db.NotificationLogs.Where(l => l.TriggeredAlertId == afterSecond.Id).ToListAsync());
}
[Fact]
public async Task Streak_ResetsOnAboveThreshold()
{
using var db = TestAppDbContextFactory.Create();
var policy = RiskyUsersPolicy();
db.AlertPolicies.Add(policy);
db.TriggeredAlerts.Add(new TriggeredAlert
{
Id = Guid.NewGuid(),
PolicyId = policy.Id,
PolicyName = policy.Name,
Severity = policy.Severity,
Category = policy.Category,
Condition = policy.Condition,
MetricValue = 5,
Threshold = policy.Threshold,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
Status = "new",
BelowThresholdStreakCount = 1, // already had one below observation
});
await db.SaveChangesAsync();
SeedOpenRiskyUsers(db, count: 5); // metric = 5, above threshold
var evaluator = BuildEvaluator(db);
await evaluator.EvaluateAsync(CancellationToken.None);
var alert = await db.TriggeredAlerts.SingleAsync();
Assert.Equal(0, alert.BelowThresholdStreakCount);
Assert.Equal("new", alert.Status);
}
[Fact]
public async Task AutoResolve_DebounceOneWithCyclesEqualsOne()
{
using var db = TestAppDbContextFactory.Create();
var policy = RiskyUsersPolicy();
db.AlertPolicies.Add(policy);
db.TriggeredAlerts.Add(new TriggeredAlert
{
Id = Guid.NewGuid(),
PolicyId = policy.Id,
PolicyName = policy.Name,
Severity = policy.Severity,
Category = policy.Category,
Condition = policy.Condition,
MetricValue = 5,
Threshold = policy.Threshold,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
Status = "new",
});
await db.SaveChangesAsync();
SeedOpenRiskyUsers(db, count: 0);
var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1);
await evaluator.EvaluateAsync(CancellationToken.None);
var alert = await db.TriggeredAlerts.SingleAsync();
Assert.Equal("auto_resolved", alert.Status);
}
[Fact]
public async Task AutoResolve_SkipsTerminalStates()
{
using var db = TestAppDbContextFactory.Create();
var policy = RiskyUsersPolicy();
db.AlertPolicies.Add(policy);
var resolvedId = Guid.NewGuid();
var autoResolvedId = Guid.NewGuid();
db.TriggeredAlerts.AddRange(
new TriggeredAlert
{
Id = resolvedId,
PolicyId = policy.Id,
PolicyName = policy.Name,
Severity = policy.Severity,
Category = policy.Category,
Condition = policy.Condition,
MetricValue = 5,
Threshold = policy.Threshold,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
Status = "resolved",
BelowThresholdStreakCount = 1,
},
new TriggeredAlert
{
Id = autoResolvedId,
PolicyId = policy.Id,
PolicyName = policy.Name,
Severity = policy.Severity,
Category = policy.Category,
Condition = policy.Condition,
MetricValue = 5,
Threshold = policy.Threshold,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
Status = "auto_resolved",
BelowThresholdStreakCount = 2,
});
await db.SaveChangesAsync();
SeedOpenRiskyUsers(db, count: 0);
var evaluator = BuildEvaluator(db);
await evaluator.EvaluateAsync(CancellationToken.None);
// The auto-resolve loop filters out terminal-state alerts, so it
// does not touch their streak counter, their status, or stamp a
// LastEvaluatedAt on them. A terminal state cannot re-open.
var resolvedRow = await db.TriggeredAlerts.AsNoTracking().SingleAsync(a => a.Id == resolvedId);
Assert.Equal("resolved", resolvedRow.Status);
Assert.Equal(1, resolvedRow.BelowThresholdStreakCount);
Assert.Null(resolvedRow.LastEvaluatedAt);
var autoResolvedRow = await db.TriggeredAlerts.AsNoTracking().SingleAsync(a => a.Id == autoResolvedId);
Assert.Equal("auto_resolved", autoResolvedRow.Status);
Assert.Equal(2, autoResolvedRow.BelowThresholdStreakCount);
Assert.Null(autoResolvedRow.LastEvaluatedAt);
}
[Fact]
public async Task AutoResolve_DoesNotDispatchNotification()
{
using var db = TestAppDbContextFactory.Create();
var policy = RiskyUsersPolicy();
db.AlertPolicies.Add(policy);
// Pre-seed a notification settings row with a Teams URL that would
// fail to deliver. If the auto-resolve path accidentally calls
// DispatchAsync, the failure would be logged here.
db.NotificationSettings.Add(new NotificationSettings
{
Id = 1,
TeamsEnabled = true,
TeamsWebhookUrl = "http://127.0.0.1:1/this-port-is-closed",
EmailEnabled = false,
WebhookEnabled = false,
SmtpPort = 587,
MinSeverity = "low",
});
var alertId = Guid.NewGuid();
db.TriggeredAlerts.Add(new TriggeredAlert
{
Id = alertId,
PolicyId = policy.Id,
PolicyName = policy.Name,
Severity = policy.Severity,
Category = policy.Category,
Condition = policy.Condition,
MetricValue = 5,
Threshold = policy.Threshold,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
Status = "new",
});
await db.SaveChangesAsync();
SeedOpenRiskyUsers(db, count: 0);
var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1);
await evaluator.EvaluateAsync(CancellationToken.None);
var alert = await db.TriggeredAlerts.SingleAsync();
Assert.Equal("auto_resolved", alert.Status);
// No log entry for this alert: auto-resolve is silent.
Assert.Empty(await db.NotificationLogs.Where(l => l.TriggeredAlertId == alert.Id).ToListAsync());
}
[Fact]
public async Task AutoResolve_PreservesAcknowledgedAt()
{
using var db = TestAppDbContextFactory.Create();
var policy = RiskyUsersPolicy();
db.AlertPolicies.Add(policy);
var ackAt = DateTimeOffset.UtcNow.AddMinutes(-15);
db.TriggeredAlerts.Add(new TriggeredAlert
{
Id = Guid.NewGuid(),
PolicyId = policy.Id,
PolicyName = policy.Name,
Severity = policy.Severity,
Category = policy.Category,
Condition = policy.Condition,
MetricValue = 5,
Threshold = policy.Threshold,
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30),
Status = "acknowledged",
AcknowledgedAt = ackAt,
AcknowledgedBy = "dashboard",
});
await db.SaveChangesAsync();
SeedOpenRiskyUsers(db, count: 0);
var evaluator = BuildEvaluator(db, autoResolveDebounceCycles: 1);
await evaluator.EvaluateAsync(CancellationToken.None);
var alert = await db.TriggeredAlerts.SingleAsync();
Assert.Equal("auto_resolved", alert.Status);
Assert.Equal(ackAt, alert.AcknowledgedAt);
Assert.Equal("dashboard", alert.AcknowledgedBy);
}
/// <summary>No-op <see cref="IHttpClientFactory"/> for tests that never make HTTP calls.</summary>
private sealed class NullHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\M365SecurityDashboard.Api\M365SecurityDashboard.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
using M365SecurityDashboard.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace M365SecurityDashboard.Api.Tests;
/// <summary>
/// Test helper that returns a fresh in-memory <see cref="AppDbContext"/>
/// for each call. Each test gets its own database (named by a unique Guid)
/// so state cannot leak between tests.
/// </summary>
internal static class TestAppDbContextFactory
{
public static AppDbContext Create()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new AppDbContext(options);
}
}
@@ -76,6 +76,18 @@ public static class AlertingSchema
IF COL_LENGTH(N'[CollectionRuns]', 'SourceFailureDetails') IS NULL
ALTER TABLE [CollectionRuns] ADD [SourceFailureDetails] nvarchar(max) NULL;
IF COL_LENGTH(N'[TriggeredAlerts]', 'SnoozedUntil') IS NULL
ALTER TABLE [TriggeredAlerts] ADD [SnoozedUntil] datetimeoffset NULL;
IF COL_LENGTH(N'[TriggeredAlerts]', 'SnoozedBy') IS NULL
ALTER TABLE [TriggeredAlerts] ADD [SnoozedBy] nvarchar(120) NULL;
IF COL_LENGTH(N'[TriggeredAlerts]', 'BelowThresholdStreakCount') IS NULL
ALTER TABLE [TriggeredAlerts] ADD [BelowThresholdStreakCount] int NOT NULL CONSTRAINT [DF_TriggeredAlerts_Streak] DEFAULT 0;
IF COL_LENGTH(N'[TriggeredAlerts]', 'LastEvaluatedAt') IS NULL
ALTER TABLE [TriggeredAlerts] ADD [LastEvaluatedAt] datetimeoffset NULL;
""";
private static readonly (string Name, string Category, string Metric, int Threshold, string Severity, string Condition)[] Defaults =
@@ -0,0 +1,16 @@
namespace M365SecurityDashboard.Api.Models;
/// <summary>
/// Configuration for the server-side alerting engine. Bound from the
/// "Alerting" section of <c>appsettings.json</c>.
/// </summary>
public sealed class AlertingOptions
{
/// <summary>
/// Number of consecutive evaluation cycles the underlying metric must be
/// below a triggered alert's threshold before the alert auto-resolves.
/// With the default collection interval of 15 minutes and a value of 2,
/// an alert auto-resolves ~30 minutes after the metric recovers.
/// </summary>
public int AutoResolveDebounceCycles { get; set; } = 2;
}
@@ -39,4 +39,17 @@ public sealed class TriggeredAlert
/// <summary>Whether outbound notifications were dispatched for this alert.</summary>
public bool Notified { get; set; }
/// <summary>If set, the alert is silenced until this timestamp. Status remains "new" or "acknowledged".</summary>
public DateTimeOffset? SnoozedUntil { get; set; }
/// <summary>Identity of the actor who snoozed this alert. Placeholder string ("dashboard") until auth lands.</summary>
[MaxLength(120)]
public string? SnoozedBy { get; set; }
/// <summary>Number of consecutive evaluation cycles the underlying metric has been below this alert's threshold. Reset on any above-threshold observation.</summary>
public int BelowThresholdStreakCount { get; set; }
/// <summary>When the evaluator last inspected this alert (used for diagnostics and the auto-resolve debounce).</summary>
public DateTimeOffset? LastEvaluatedAt { get; set; }
}
+33
View File
@@ -10,6 +10,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService();
builder.Services.Configure<GraphOptions>(builder.Configuration.GetSection("Graph"));
builder.Services.Configure<AlertingOptions>(builder.Configuration.GetSection("Alerting"));
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddHttpClient<GraphApiClient>();
@@ -1077,6 +1078,35 @@ app.MapPost("/api/triggered-alerts/{id:guid}/resolve", async (AppDbContext db, G
return Results.Ok(t);
});
// Per-alert snooze. Body: { "until": "2026-06-22T18:00:00Z" } or { "durationHours": 4|24|168 }.
// Until wins if both are supplied; durationHours defaults to 24 if neither is supplied.
app.MapPost("/api/triggered-alerts/{id:guid}/snooze", async (
AppDbContext db, Guid id, SnoozeRequest input, CancellationToken ct) =>
{
var t = await db.TriggeredAlerts.FindAsync([id], ct);
if (t is null) return Results.NotFound();
if (t.Status is "resolved" or "auto_resolved")
return Results.BadRequest(new { error = "Cannot snooze a terminal alert." });
var until = input.Until
?? (input.DurationHours is { } h ? DateTimeOffset.UtcNow.AddHours(h) : DateTimeOffset.UtcNow.AddHours(24));
t.SnoozedUntil = until;
t.SnoozedBy = "dashboard";
await db.SaveChangesAsync(ct);
return Results.Ok(t);
});
app.MapPost("/api/triggered-alerts/{id:guid}/unsnooze", async (
AppDbContext db, Guid id, CancellationToken ct) =>
{
var t = await db.TriggeredAlerts.FindAsync([id], ct);
if (t is null) return Results.NotFound();
t.SnoozedUntil = null;
t.SnoozedBy = null;
await db.SaveChangesAsync(ct);
return Results.Ok(t);
});
// Manually run an evaluation pass (used by the dashboard "refresh" + on-demand check)
app.MapPost("/api/alert-policies/evaluate", async (AlertEvaluator evaluator, CancellationToken ct) =>
{
@@ -1150,3 +1180,6 @@ app.MapGet("/api/notification-log", async (AppDbContext db, CancellationToken ct
app.MapFallbackToFile("index.html");
app.Run();
/// <summary>Body shape for POST /api/triggered-alerts/{id}/snooze.</summary>
public sealed record SnoozeRequest(DateTimeOffset? Until, int? DurationHours);
@@ -1,6 +1,7 @@
using M365SecurityDashboard.Api.Data;
using M365SecurityDashboard.Api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace M365SecurityDashboard.Api.Services;
@@ -8,10 +9,12 @@ namespace M365SecurityDashboard.Api.Services;
/// Evaluates all enabled <see cref="AlertPolicy"/> rows against the latest
/// collected data and persists new <see cref="TriggeredAlert"/> rows. Runs
/// server-side after every collection cycle so alerts fire without a browser.
/// Also auto-resolves stale alerts whose underlying metric has recovered.
/// </summary>
public sealed class AlertEvaluator(
AppDbContext db,
NotificationSender sender,
IOptions<AlertingOptions> options,
ILogger<AlertEvaluator> logger)
{
public async Task<int> EvaluateAsync(CancellationToken ct)
@@ -26,6 +29,10 @@ public sealed class AlertEvaluator(
var now = DateTimeOffset.UtcNow;
var fired = 0;
// Map PolicyId -> Metric key so the auto-resolve loop below can look up
// each open alert's current metric without re-querying the policy table.
var policyMetricById = policies.ToDictionary(p => p.Id, p => p.Metric);
foreach (var policy in policies)
{
var value = metrics.GetValueOrDefault(policy.Metric, 0);
@@ -67,10 +74,41 @@ public sealed class AlertEvaluator(
}
}
if (fired > 0)
// Auto-resolve: scan non-terminal alerts and update streak counters.
// Resolves silently — no notification dispatch, no NotificationLog write.
var streakTarget = Math.Max(1, options.Value.AutoResolveDebounceCycles);
var openAlerts = await db.TriggeredAlerts
.Where(t => t.Status != "resolved" && t.Status != "auto_resolved")
.ToListAsync(ct);
var autoResolved = 0;
foreach (var alert in openAlerts)
{
if (!policyMetricById.TryGetValue(alert.PolicyId, out var metricKey)) continue;
var current = metrics.GetValueOrDefault(metricKey, 0);
if (current < alert.Threshold)
{
alert.BelowThresholdStreakCount++;
if (alert.BelowThresholdStreakCount >= streakTarget)
{
alert.Status = "auto_resolved";
autoResolved++;
}
}
else if (alert.BelowThresholdStreakCount != 0)
{
alert.BelowThresholdStreakCount = 0;
}
alert.LastEvaluatedAt = now;
}
if (fired > 0 || autoResolved > 0)
{
await db.SaveChangesAsync(ct);
logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired);
if (fired > 0)
logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired);
if (autoResolved > 0)
logger.LogInformation("Auto-resolved {Count} alert(s) after metric recovery", autoResolved);
}
return fired;
}
@@ -13,5 +13,8 @@
"ExchangeQuarantinePath": "/beta/security/alerts_v2?$top=50&$filter=serviceSource%20eq%20'microsoftDefenderForOffice365'%20and%20category%20eq%20'EmailMalware'",
"MailFlowIssuesPath": "/v1.0/admin/serviceAnnouncement/issues?$top=50&$filter=service%20eq%20'Exchange%20Online'%20and%20isResolved%20eq%20false"
},
"Alerting": {
"AutoResolveDebounceCycles": 2
},
"AllowedHosts": "*"
}
@@ -153,9 +153,13 @@ interface TriggeredAlert {
metricValue: number;
threshold: number;
triggeredAt: string;
status: "new" | "acknowledged" | "resolved";
status: "new" | "acknowledged" | "snoozed" | "auto_resolved" | "resolved";
acknowledgedAt?: string;
acknowledgedBy?: string;
snoozedUntil?: string;
snoozedBy?: string;
belowThresholdStreakCount?: number;
lastEvaluatedAt?: string;
}
interface NotificationSettings {
@@ -212,6 +216,12 @@ const acApi = {
async getLog(): Promise<NotificationLogEntry[]> {
try { const r = await fetch(`${apiBase}/api/notification-log`); return r.ok ? await r.json() : []; } catch { return []; }
},
async snooze(id: string, durationHours: 4 | 24 | 168): Promise<boolean> {
try { const r = await fetch(`${apiBase}/api/triggered-alerts/${id}/snooze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ durationHours }) }); return r.ok; } catch { return false; }
},
async unsnooze(id: string): Promise<boolean> {
try { const r = await fetch(`${apiBase}/api/triggered-alerts/${id}/unsnooze`, { method: "POST" }); return r.ok; } catch { return false; }
},
};
const SEVERITIES: AlertSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"];
@@ -3508,7 +3518,11 @@ function sevToneAC(s: string): Tone {
return s === "critical" ? "error" : s === "high" ? "error" : s === "medium" ? "warning" : "info";
}
function statusTone(s: string): Tone {
return s === "new" ? "error" : s === "acknowledged" ? "warning" : "good";
return s === "new" ? "error"
: s === "acknowledged" ? "warning"
: s === "snoozed" ? "neutral"
: s === "auto_resolved" ? "info"
: "good"; // resolved
}
function PolicyModal({ policy, onSave, onClose }: {
@@ -3784,6 +3798,14 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
if (await acApi.resolve(id)) { showToast("Alert resolved"); await onChanged(); }
};
const snooze = async (id: string, durationHours: 4 | 24 | 168) => {
if (await acApi.snooze(id, durationHours)) { showToast(`Snoozed for ${durationHours}h`); await onChanged(); }
};
const unsnooze = async (id: string) => {
if (await acApi.unsnooze(id)) { showToast("Snooze cleared"); await onChanged(); }
};
const handleSavePolicy = async (p: AlertPolicy) => {
const exists = policies.some(x => x.id === p.id);
const ok = exists ? await acApi.updatePolicy(p) : !!(await acApi.createPolicy(p));
@@ -3845,6 +3867,8 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
<DetailField label="Status" value={selectedTriggered.status}/>
<DetailField label="Triggered" value={fmtDate(selectedTriggered.triggeredAt)}/>
{selectedTriggered.acknowledgedAt && <DetailField label="Acknowledged" value={fmtDate(selectedTriggered.acknowledgedAt)}/>}
{selectedTriggered.snoozedUntil && <DetailField label="Snoozed until" value={fmtDate(selectedTriggered.snoozedUntil)}/>}
{selectedTriggered.lastEvaluatedAt && <DetailField label="Last evaluated" value={fmtDate(selectedTriggered.lastEvaluatedAt)}/>}
</div>
<div className="detail-modal-footer">
<button className="dm-close-btn" onClick={() => setSelectedTriggered(null)}>Close</button>
@@ -3946,6 +3970,9 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
<span className={`sev-dot sev-${a.severity}`}/>
<span className="mr-user" style={{ flex:1 }}>{a.policyName}</span>
<Badge label={a.status} tone={statusTone(a.status)}/>
{a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && (
<span style={{ fontSize:10, color:"var(--color-muted)" }}>Z until {relTime(a.snoozedUntil)}</span>
)}
<span className="mr-date">{relTime(a.triggeredAt)}</span>
{a.status === "new" && (
<button className="btn-ack" onClick={e => { e.stopPropagation(); acknowledge(a.id); }}>Ack</button>
@@ -3980,6 +4007,8 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
<option value="">All statuses</option>
<option value="new">New</option>
<option value="acknowledged">Acknowledged</option>
<option value="snoozed">Snoozed</option>
<option value="auto_resolved">Auto-resolved</option>
<option value="resolved">Resolved</option>
</select>
<ExportDropdown rows={filteredTA.map(a=>({ Policy:a.policyName, Severity:a.severity, Category:a.category, Condition:a.condition, MetricValue:a.metricValue, Threshold:a.threshold, Triggered:a.triggeredAt, Status:a.status }))} filename="triggered-alerts.csv"/>
@@ -4007,10 +4036,29 @@ function AlertCenterPage({ policies, triggeredAlerts, onChanged }: {
<td style={{ fontWeight:600 }}>{a.metricValue}</td>
<td>{a.threshold}</td>
<td className="al-date">{relTime(a.triggeredAt)}</td>
<td><Badge label={a.status} tone={statusTone(a.status)}/></td>
<td onClick={e=>e.stopPropagation()} style={{ display:"flex", gap:4, alignItems:"center" }}>
<td><Badge label={a.status} tone={statusTone(a.status)}/>
{a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && (
<div style={{ fontSize:10, color:"var(--color-muted)", marginTop:2 }}>snoozed until {relTime(a.snoozedUntil)}</div>
)}
</td>
<td onClick={e=>e.stopPropagation()} style={{ display:"flex", gap:4, alignItems:"center", flexWrap:"wrap" }}>
{a.status === "new" && <button className="btn-ack" onClick={()=>acknowledge(a.id)}>Acknowledge</button>}
{a.status !== "resolved" && <button className="btn-resolve" onClick={()=>resolve(a.id)}>Resolve</button>}
{a.status !== "resolved" && a.status !== "auto_resolved" && (
<select className="filter-sel" style={{ padding:"3px 6px", fontSize:11 }} defaultValue=""
onChange={e => { const h = Number(e.target.value); if (h) snooze(a.id, h as 4|24|168); e.currentTarget.value = ""; }}
title="Snooze for…">
<option value="" disabled>Snooze…</option>
<option value="4">4h</option>
<option value="24">24h</option>
<option value="168">7d</option>
</select>
)}
{a.snoozedUntil && new Date(a.snoozedUntil) > new Date() && (
<button className="btn-apply" style={{ padding:"3px 8px", fontSize:11 }}
onClick={() => unsnooze(a.id)}
title={`Snoozed until ${a.snoozedUntil}`}>Unsnooze</button>
)}
{a.status !== "resolved" && a.status !== "auto_resolved" && <button className="btn-resolve" onClick={()=>resolve(a.id)}>Resolve</button>}
</td>
</tr>
))}