diff --git a/src/Vantage.Core/Alerts/BufferedDesktopNotifier.cs b/src/Vantage.Core/Alerts/BufferedDesktopNotifier.cs new file mode 100644 index 0000000..0dde9ba --- /dev/null +++ b/src/Vantage.Core/Alerts/BufferedDesktopNotifier.cs @@ -0,0 +1,26 @@ +namespace Vantage.Core.Alerts; + +public sealed class BufferedDesktopNotifier : IDesktopNotifier +{ + public readonly record struct Toast(string Title, string Body, AlertSeverity Severity, DateTimeOffset At); + + private readonly object gate = new(); + private readonly List pending = new(); + + public Task ShowAsync(string title, string body, AlertSeverity severity, CancellationToken cancellationToken = default) + { + lock (gate) + pending.Add(new Toast(title, body, severity, DateTimeOffset.UtcNow)); + return Task.CompletedTask; + } + + public IReadOnlyList DrainPending() + { + lock (gate) + { + var drained = pending.ToArray(); + pending.Clear(); + return drained; + } + } +} diff --git a/src/Vantage.Core/Alerts/DesktopAlertSink.cs b/src/Vantage.Core/Alerts/DesktopAlertSink.cs new file mode 100644 index 0000000..4b79f3f --- /dev/null +++ b/src/Vantage.Core/Alerts/DesktopAlertSink.cs @@ -0,0 +1,19 @@ +namespace Vantage.Core.Alerts; + +public sealed class DesktopAlertSink : IAlertSink +{ + private readonly IDesktopNotifier notifier; + + public DesktopAlertSink(IDesktopNotifier notifier) + { + this.notifier = notifier; + } + + public Task NotifyAsync(AlertNotification notification, CancellationToken cancellationToken = default) + { + var title = notification.Kind == AlertTransitionKind.Fired + ? $"Alert: {notification.RuleName}" + : $"Resolved: {notification.RuleName}"; + return notifier.ShowAsync(title, notification.Summary(), notification.Severity, cancellationToken); + } +} diff --git a/src/Vantage.Core/Alerts/IAlertSink.cs b/src/Vantage.Core/Alerts/IAlertSink.cs new file mode 100644 index 0000000..cce75e5 --- /dev/null +++ b/src/Vantage.Core/Alerts/IAlertSink.cs @@ -0,0 +1,6 @@ +namespace Vantage.Core.Alerts; + +public interface IAlertSink +{ + Task NotifyAsync(AlertNotification notification, CancellationToken cancellationToken = default); +} diff --git a/src/Vantage.Core/Alerts/IDesktopNotifier.cs b/src/Vantage.Core/Alerts/IDesktopNotifier.cs new file mode 100644 index 0000000..af8036e --- /dev/null +++ b/src/Vantage.Core/Alerts/IDesktopNotifier.cs @@ -0,0 +1,6 @@ +namespace Vantage.Core.Alerts; + +public interface IDesktopNotifier +{ + Task ShowAsync(string title, string body, AlertSeverity severity, CancellationToken cancellationToken = default); +} diff --git a/src/Vantage.Core/Alerts/LogAlertSink.cs b/src/Vantage.Core/Alerts/LogAlertSink.cs new file mode 100644 index 0000000..0519aed --- /dev/null +++ b/src/Vantage.Core/Alerts/LogAlertSink.cs @@ -0,0 +1,17 @@ +namespace Vantage.Core.Alerts; + +public sealed class LogAlertSink : IAlertSink +{ + private readonly IAlertLog log; + + public LogAlertSink(IAlertLog log) + { + this.log = log; + } + + public Task NotifyAsync(AlertNotification notification, CancellationToken cancellationToken = default) + { + log.Append(new AlertLogEntry(notification.Timestamp, notification.Severity, notification.Summary())); + return Task.CompletedTask; + } +} diff --git a/src/Vantage.Core/Alerts/WebhookAlertSink.cs b/src/Vantage.Core/Alerts/WebhookAlertSink.cs new file mode 100644 index 0000000..4ba4ea1 --- /dev/null +++ b/src/Vantage.Core/Alerts/WebhookAlertSink.cs @@ -0,0 +1,52 @@ +using System.Net.Http.Json; + +namespace Vantage.Core.Alerts; + +public sealed class WebhookAlertSink : IAlertSink +{ + private readonly HttpClient httpClient; + private readonly Uri endpoint; + private readonly TimeSpan timeout; + + public WebhookAlertSink(HttpClient httpClient, Uri endpoint, TimeSpan? timeout = null) + { + this.httpClient = httpClient; + this.endpoint = endpoint; + this.timeout = timeout ?? TimeSpan.FromSeconds(10); + } + + public async Task NotifyAsync(AlertNotification notification, CancellationToken cancellationToken = default) + { + var payload = new WebhookPayload( + Status: notification.Kind == AlertTransitionKind.Fired ? "firing" : "resolved", + Severity: notification.Severity.ToString().ToLowerInvariant(), + Rule: notification.RuleName, + Host: notification.HostId, + Metric: notification.Metric, + Value: notification.Value, + Threshold: notification.Threshold, + Comparison: notification.ComparisonSymbol, + Summary: notification.Summary(), + Timestamp: notification.Timestamp.ToUnixTimeMilliseconds()); + + using var timeoutCts = new CancellationTokenSource(timeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + using var response = await httpClient + .PostAsJsonAsync(endpoint, payload, linked.Token) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + private sealed record WebhookPayload( + string Status, + string Severity, + string Rule, + string Host, + string Metric, + double Value, + double Threshold, + string Comparison, + string Summary, + long Timestamp); +}