diff --git a/Directory.Packages.props b/Directory.Packages.props index 8628728..fc64c15 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/deploy/grafana/sqlinsight-dashboard.json b/deploy/grafana/sqlinsight-dashboard.json new file mode 100644 index 0000000..d5f97fb --- /dev/null +++ b/deploy/grafana/sqlinsight-dashboard.json @@ -0,0 +1,700 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "title": "SQLInsight Pro", + "uid": "sqlinsight-pro", + "tags": [ + "sqlserver", + "sqlinsight" + ], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "1m", + "time": { + "from": "now-24h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "instance", + "label": "Instans", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "query": { + "query": "label_values(sqlinsight_instance_up, instance)", + "refId": "q" + }, + "definition": "label_values(sqlinsight_instance_up, instance)", + "includeAll": true, + "multi": true, + "current": { + "text": "All", + "value": "$__all" + }, + "refresh": 2, + "allValue": ".*" + } + ] + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Instanser nede", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "count(sqlinsight_instance_up{instance=~\"$instance\"} == 0) or vector(0)", + "legendFormat": "", + "refId": "A", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 2, + "type": "stat", + "title": "Kritiske fund", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum(sqlinsight_findings{instance=~\"$instance\",severity=\"critical\"})", + "legendFormat": "", + "refId": "A", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 3, + "type": "stat", + "title": "Høje fund", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum(sqlinsight_findings{instance=~\"$instance\",severity=\"high\"})", + "legendFormat": "", + "refId": "A", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 4, + "type": "stat", + "title": "Middel fund", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum(sqlinsight_findings{instance=~\"$instance\",severity=\"medium\"})", + "legendFormat": "", + "refId": "A", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 5, + "type": "stat", + "title": "Ikke målbare regler", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum(sqlinsight_rules_not_measurable{instance=~\"$instance\"})", + "legendFormat": "", + "refId": "A", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "blue", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 6, + "type": "stat", + "title": "Instanser", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "count(sqlinsight_instance_up{instance=~\"$instance\"})", + "legendFormat": "", + "refId": "A", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value" + } + }, + { + "id": 7, + "type": "timeseries", + "title": "Aktive fund (kritisk + høj)", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 4 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sum by (instance) (sqlinsight_findings{instance=~\"$instance\",severity=~\"critical|high\"})", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "none" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 8, + "type": "timeseries", + "title": "CPU brugt af SQL Server", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 4 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sqlinsight_metric{instance=~\"$instance\",metric=\"cpu.sqlserver\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 9, + "type": "timeseries", + "title": "Page life expectancy", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 12 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sqlinsight_metric{instance=~\"$instance\",metric=\"memory.ple\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 10, + "type": "timeseries", + "title": "Batch requests", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 12 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sqlinsight_metric{instance=~\"$instance\",metric=\"workload.batch_requests\"}", + "legendFormat": "{{instance}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 11, + "type": "table", + "title": "Fund pr. regel", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 20 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sqlinsight_rule_findings{instance=~\"$instance\"}", + "legendFormat": "", + "refId": "A", + "format": "table", + "instant": true, + "range": false + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "job": true + }, + "renameByName": { + "instance": "Instans", + "rule": "Regel", + "severity": "Alvor", + "Value": "Antal" + } + } + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "Antal", + "desc": true + } + ] + } + }, + { + "id": 12, + "type": "table", + "title": "Versioner", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 30 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "expr": "sqlinsight_instance_info{instance=~\"$instance\"}", + "legendFormat": "", + "refId": "A", + "format": "table", + "instant": true, + "range": false + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "job": true, + "Value": true + }, + "renameByName": { + "instance": "Instans", + "version": "Version", + "level": "Niveau", + "edition": "Edition" + } + } + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "showHeader": true + } + } + ] +} \ No newline at end of file diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml new file mode 100644 index 0000000..47e1679 --- /dev/null +++ b/deploy/prometheus/prometheus.yml @@ -0,0 +1,11 @@ +# Scrape SQLInsight Web. Create the token with: SQLInsight.Web new-api-token prometheus +scrape_configs: + - job_name: sqlinsight + scheme: https + scrape_interval: 60s + metrics_path: /metrics + authorization: + type: Bearer + credentials_file: /etc/prometheus/sqlinsight.token + static_configs: + - targets: ["sqlinsight.example.local"] diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..5fdd259 --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,105 @@ +# Integration med eksisterende overvågning + +SQLInsight Pro kan levere data til de værktøjer, driften allerede bruger: + +| Værktøj | Sådan | +|---|---| +| Prometheus og Grafana | `/metrics` i webbrugerfladen, og et færdigt Grafana-dashboard | +| Scripts, ITSM, egne dashboards | JSON-API under `/api/v1/` | +| System Center Operations Manager (SCOM) og andre, der læser Windows' hændelseslog | Agenten skriver hændelser med faste event-id'er | +| Teams, mail og webhooks | Se notifikationer i [installation.md](installation.md) | + +Alt er skrivebeskyttet: intet af det kan ændre noget i SQLInsight Pro eller på jeres SQL Servere. + +## API-tokens + +Brugere logger ind i webbrugerfladen med Windows-login. Maskiner som Prometheus kan i stedet bruge et token: + +```powershell +& "C:\Program Files\SQLInsight\Web\SQLInsight.Web.exe" new-api-token prometheus +``` + +Kommandoen skriver selve tokenet (gem det i Prometheus) og en linje til `appsettings.json`: + +```json +"SQLInsight": { + "Web": { + "ApiTokens": { "prometheus": "sha256:4F1C…" } + } +} +``` + +Kun tokenets SHA-256 gemmes i konfigurationen. Et token giver kun adgang til `/api/` og `/metrics`, aldrig til websiderne. Fjern linjen for at tilbagekalde tokenet. Windows-login virker også på API'et. + +## Prometheus og Grafana + +`deploy/prometheus/prometheus.yml` viser scrape-konfigurationen. Tokenet lægges i en fil, som `credentials_file` peger på. Hent data højst hvert minut; agenten måler selv med sine egne intervaller. + +| Metric | Labels | Betydning | +|---|---|---| +| `sqlinsight_instance_up` | instance | 1, hvis seneste kontakt lykkedes inden for 10 minutter | +| `sqlinsight_instance_last_seen_timestamp_seconds` | instance | Tidspunkt for seneste vellykkede kontakt | +| `sqlinsight_instance_info` | instance, version, level, edition | Version og CU-niveau (værdi 1) | +| `sqlinsight_findings` | instance, severity | Antal aktive fund pr. alvor | +| `sqlinsight_rule_findings` | instance, rule, severity | Antal aktive fund pr. regel | +| `sqlinsight_rules_not_measurable` | instance | Regler, der ikke kunne vurderes | +| `sqlinsight_metric` | instance, metric | Seneste måling: `cpu.sqlserver`, `cpu.other` (%), `memory.ple` (s), `memory.grants_pending`, `workload.batch_requests`, `workload.compilations` (pr. s), `connections.user`, `blocking.processes` | +| `sqlinsight_build_info` | version, rulepack | SQLInsight Pro-version og regelpakke | + +Eksempel på alarm i Prometheus: + +```yaml +- alert: SqlInsightCriticalFinding + expr: sqlinsight_findings{severity="critical"} > 0 + for: 5m + annotations: + summary: "{{ $labels.instance }} har {{ $value }} kritiske fund i SQLInsight Pro" +``` + +**Grafana:** importér `deploy/grafana/sqlinsight-dashboard.json` (Dashboards → New → Import) og vælg jeres Prometheus-datakilde. Dashboardet viser instanser, der er nede, fund pr. alvor, CPU, page life expectancy, batch requests, fund pr. regel og versioner. + +## JSON-API + +| Kald | Svar | +|---|---| +| `GET /api/v1/instances` | Instanser med version, seneste kontakt, `up` og antal fund pr. alvor | +| `GET /api/v1/findings?instance=prod-sql01&minSeverity=High` | Aktive fund med forklaring, anbefaling og målinger | +| `GET /metrics` | Prometheus-format | + +```powershell +Invoke-RestMethod https://sqlinsight.domæne.local/api/v1/findings?minSeverity=High -Headers @{ Authorization = "Bearer $token" } +``` + +Følsomme målinger (forespørgselstekst, logins, værtsnavne) vises som `[udeladt]` i API'et. Sæt `SQLInsight:Web:ApiIncludesSensitiveEvidence` til `true` for at få dem med. + +## SCOM og Windows' hændelseslog + +Slå kanalen til i agentens `appsettings.json`: + +```json +"SQLInsight": { + "Notifications": { + "EventLog": { "Enabled": true, "Source": "SQLInsight Pro", "MinSeverity": "Medium" } + } +} +``` + +Kilden registreres én gang som administrator: + +```powershell +New-EventLog -LogName Application -Source "SQLInsight Pro" +``` + +Hændelserne skrives i loggen **Application**: + +| Event-id | Niveau | Hændelse | +|---|---|---| +| 1004 | Error | Nyt kritisk fund | +| 1003 | Error | Nyt fund med høj alvor | +| 1002 | Warning | Nyt fund med middel alvor | +| 1001 / 1000 | Information | Nyt fund med lav alvor / info | +| 2000 | Information | Fund løst | +| 3000 | Error | Instansen kan ikke nås | +| 3001 | Information | Instansen svarer igen | + +Beskeden starter med en overskrift og har derefter én linje pr. felt (`Instans:`, `Regel:`, `Emne:`, `Alvor:`, `Beskrivelse:`, `Link:`). I SCOM oprettes en regel eller monitor af typen *Windows Event* på kilden `SQLInsight Pro` med de event-id'er, der skal give alarm. Brug fx 1004 og 3000 til alarm og 2000 og 3001 til at lukke den igen. Hændelsesloggen er ikke omfattet af loftet over notifikationer pr. time, så ingen hændelser går tabt. diff --git a/src/SQLInsight.Agent/Program.cs b/src/SQLInsight.Agent/Program.cs index 5ae3ab8..b4fe34e 100644 --- a/src/SQLInsight.Agent/Program.cs +++ b/src/SQLInsight.Agent/Program.cs @@ -58,6 +58,11 @@ builder.Services.AddSingleton(sp => channels.Add(new WebhookChannel(options.Webhook, http.CreateClient("notifications"))); } + if (options.EventLog.Enabled && OperatingSystem.IsWindows()) + { + channels.Add(new EventLogChannel(options.EventLog, new WindowsEventLogWriter())); + } + return new NotificationDispatcher(channels, options, sp.GetRequiredService(), sp.GetRequiredService>()); }); builder.Services.Configure(builder.Configuration.GetSection(SQLInsight.Agent.Hub.HubOptions.SectionName)); diff --git a/src/SQLInsight.Notifications/EventLogChannel.cs b/src/SQLInsight.Notifications/EventLogChannel.cs new file mode 100644 index 0000000..e044b5a --- /dev/null +++ b/src/SQLInsight.Notifications/EventLogChannel.cs @@ -0,0 +1,103 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text; +using SQLInsight.Core.Findings; + +namespace SQLInsight.Notifications; + +public enum EventLevel +{ + Information, + Warning, + Error, +} + +public interface IEventLogWriter +{ + void Write(string source, string message, EventLevel level, int eventId); +} + +[SupportedOSPlatform("windows")] +public sealed class WindowsEventLogWriter : IEventLogWriter +{ + public void Write(string source, string message, EventLevel level, int eventId) => + EventLog.WriteEntry(source, message, level switch + { + EventLevel.Error => EventLogEntryType.Error, + EventLevel.Warning => EventLogEntryType.Warning, + _ => EventLogEntryType.Information, + }, eventId); +} + +/// +/// Writes each notification to the Windows Event Log (Application) with a fixed event id: +/// +/// 1000–1004New finding: 1000 Info, 1001 Low, 1002 Medium, 1003 High, 1004 Critical +/// 2000Finding resolved +/// 3000 / 3001Instance unreachable / responding again +/// +/// +public sealed class EventLogChannel(EventLogOptions options, IEventLogWriter writer) : INotificationChannel +{ + public const int FindingOpenedBase = 1000; + public const int FindingResolved = 2000; + public const int InstanceUnreachable = 3000; + public const int InstanceRecovered = 3001; + + public string Name => "eventlog"; + + public Severity MinSeverity => options.MinSeverity; + + public bool Unlimited => true; + + public Task SendAsync(IReadOnlyList notifications, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notifications); + foreach (var n in notifications) + { + var (id, level) = Classify(n); + writer.Write(options.Source, Message(n), level, id); + } + + return Task.CompletedTask; + } + + public static (int EventId, EventLevel Level) Classify(Notification n) + { + ArgumentNullException.ThrowIfNull(n); + return n.Kind switch + { + NotificationKind.FindingOpened => (FindingOpenedBase + (int)n.Severity, n.Severity switch + { + Severity.Critical or Severity.High => EventLevel.Error, + Severity.Medium => EventLevel.Warning, + _ => EventLevel.Information, + }), + NotificationKind.FindingResolved => (FindingResolved, EventLevel.Information), + NotificationKind.InstanceUnreachable => (InstanceUnreachable, EventLevel.Error), + _ => (InstanceRecovered, EventLevel.Information), + }; + } + + /// Headline first, then one "Name: value" per line, so SCOM can parse it. + public static string Message(Notification n) + { + ArgumentNullException.ThrowIfNull(n); + var text = new StringBuilder(Texts.Headline(n)).Append("\r\n\r\n"); + void Line(string name, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + text.Append(name).Append(": ").Append(value).Append("\r\n"); + } + } + + Line("Instans", n.Instance); + Line("Regel", n.RuleId); + Line("Emne", n.Subject); + Line("Alvor", n.Kind is NotificationKind.FindingOpened or NotificationKind.FindingResolved ? Texts.Severity(n.Severity) : null); + Line("Beskrivelse", n.Summary); + Line("Link", n.Url?.ToString()); + return text.ToString(); + } +} diff --git a/src/SQLInsight.Notifications/Notification.cs b/src/SQLInsight.Notifications/Notification.cs index 8d7f979..fa94562 100644 --- a/src/SQLInsight.Notifications/Notification.cs +++ b/src/SQLInsight.Notifications/Notification.cs @@ -40,4 +40,7 @@ public interface INotificationChannel Severity MinSeverity { get; } Task SendAsync(IReadOnlyList notifications, CancellationToken cancellationToken); + + /// Exempt from the hourly cap: local channels that monitoring systems read, where a dropped event is a missed alert. + bool Unlimited => false; } diff --git a/src/SQLInsight.Notifications/NotificationDispatcher.cs b/src/SQLInsight.Notifications/NotificationDispatcher.cs index 238836d..3f2bc9a 100644 --- a/src/SQLInsight.Notifications/NotificationDispatcher.cs +++ b/src/SQLInsight.Notifications/NotificationDispatcher.cs @@ -19,7 +19,7 @@ public sealed partial class NotificationDispatcher(IEnumerable IsRelevant(channel, n)).ToList(); - if (relevant.Count == 0 || !TryReserve(channel.Name, relevant.Count)) + if (relevant.Count == 0 || (!channel.Unlimited && !TryReserve(channel.Name, relevant.Count))) { continue; } diff --git a/src/SQLInsight.Notifications/NotificationOptions.cs b/src/SQLInsight.Notifications/NotificationOptions.cs index c5b4552..7dc072e 100644 --- a/src/SQLInsight.Notifications/NotificationOptions.cs +++ b/src/SQLInsight.Notifications/NotificationOptions.cs @@ -18,6 +18,21 @@ public sealed class NotificationOptions public EmailOptions Email { get; set; } = new(); public TeamsOptions Teams { get; set; } = new(); public WebhookOptions Webhook { get; set; } = new(); + public EventLogOptions EventLog { get; set; } = new(); +} + +/// +/// Windows Event Log, for System Center Operations Manager and other tools that watch the event log. Event ids are +/// fixed (see ), so rules in SCOM can be built on them. +/// +public sealed class EventLogOptions +{ + public bool Enabled { get; set; } + + /// Event source; must be registered once by an administrator (New-EventLog). + public string Source { get; set; } = "SQLInsight Pro"; + + public Severity MinSeverity { get; set; } = Severity.Medium; } public sealed class EmailOptions diff --git a/src/SQLInsight.Notifications/SQLInsight.Notifications.csproj b/src/SQLInsight.Notifications/SQLInsight.Notifications.csproj index ce45395..9af7433 100644 --- a/src/SQLInsight.Notifications/SQLInsight.Notifications.csproj +++ b/src/SQLInsight.Notifications/SQLInsight.Notifications.csproj @@ -7,6 +7,7 @@ + diff --git a/src/SQLInsight.Repository/InsightRepository.Integration.cs b/src/SQLInsight.Repository/InsightRepository.Integration.cs new file mode 100644 index 0000000..1e67d84 --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Integration.cs @@ -0,0 +1,24 @@ +using Dapper; + +namespace SQLInsight.Repository; + +public sealed record LatestMetric(string Instance, string Metric, double Value, DateTime SampledAt); + +public sealed partial class InsightRepository +{ + /// The newest sample of each metric per instance, if it is no older than . + public async Task> GetLatestMetricsAsync(TimeSpan maxAge, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT i.Name AS Instance, m.Metric, m.Value, m.SampledAt + FROM insight.Instances i + CROSS APPLY (SELECT s.Metric, s.Value, s.SampledAt, ROW_NUMBER() OVER (PARTITION BY s.Metric ORDER BY s.SampledAt DESC) AS rn + FROM insight.MetricSamples s + WHERE s.InstanceId = i.InstanceId AND s.SampledAt >= DATEADD(second, -@seconds, SYSUTCDATETIME())) m + WHERE m.rn = 1 + ORDER BY i.Name, m.Metric; + """, new { seconds = (int)maxAge.TotalSeconds }).ConfigureAwait(false); + return rows.ToList(); + } +} diff --git a/src/SQLInsight.Web/Api/ApiTokens.cs b/src/SQLInsight.Web/Api/ApiTokens.cs new file mode 100644 index 0000000..1b8fc77 --- /dev/null +++ b/src/SQLInsight.Web/Api/ApiTokens.cs @@ -0,0 +1,57 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace SQLInsight.Web.Api; + +/// +/// Bearer tokens for machines (Prometheus, Grafana, SCOM scripts) that cannot log in with Windows authentication. +/// Only the SHA-256 of each token is configured, and a token only opens /api/ and /metrics, never the web pages. +/// +public sealed class ApiTokenHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IOptions web) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "ApiToken"; + public const string ClaimType = "sqlinsight:api-token"; + + public static bool AppliesTo(PathString path) => path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase); + + protected override Task HandleAuthenticateAsync() + { + if (!AppliesTo(Request.Path)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var header = Request.Headers.Authorization.ToString(); + if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var hash = Hash(header["Bearer ".Length..].Trim()); + foreach (var (name, expected) in web.Value.ApiTokens) + { + var configured = expected.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? expected["sha256:".Length..] : expected; + if (configured.Length == hash.Length && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(configured.ToUpperInvariant()), Encoding.ASCII.GetBytes(hash))) + { + var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "api:" + name), new Claim(ClaimType, name)], SchemeName); + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName))); + } + } + + return Task.FromResult(AuthenticateResult.Fail("Ukendt API-token.")); + } + + public static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + + /// A new random token and the line to put in appsettings.json. + public static (string Token, string Hash) Create() + { + var token = "sqli_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + return (token, "sha256:" + Hash(token)); + } +} diff --git a/src/SQLInsight.Web/Api/IntegrationEndpoints.cs b/src/SQLInsight.Web/Api/IntegrationEndpoints.cs new file mode 100644 index 0000000..30e52a5 --- /dev/null +++ b/src/SQLInsight.Web/Api/IntegrationEndpoints.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using System.Reflection; +using System.Text; +using SQLInsight.Core.Findings; +using SQLInsight.Repository; + +namespace SQLInsight.Web.Api; + +/// +/// Read-only integration: JSON for scripts and other tools, and the Prometheus text format for Prometheus/Grafana. +/// +public static class IntegrationEndpoints +{ + /// An instance counts as up when the last contact succeeded and is this recent. + public static readonly TimeSpan UpWindow = TimeSpan.FromMinutes(10); + + public static void Map(IEndpointRouteBuilder app) + { + var api = app.MapGroup("/api/v1").DisableAntiforgery(); + api.MapGet("/instances", async (InsightRepository repository, CancellationToken ct) => + (await repository.GetInstancesAsync(ct).ConfigureAwait(false)).Select(i => new + { + i.Name, + i.ProductVersion, + i.ProductLevel, + i.ProductUpdateLevel, + i.Edition, + i.LastSeenAt, + Up = IsUp(i), + i.LastError, + Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info }, + i.NotMeasurable, + })); + + api.MapGet("/findings", async (string? instance, Severity? minSeverity, InsightRepository repository, Microsoft.Extensions.Options.IOptions options, CancellationToken ct) => + { + var findings = await repository.GetActiveFindingsAsync(cancellationToken: ct).ConfigureAwait(false); + var includeSensitive = options.Value.ApiIncludesSensitiveEvidence; + return findings + .Where(f => instance is null || string.Equals(f.InstanceName, instance, StringComparison.OrdinalIgnoreCase)) + .Where(f => minSeverity is null || f.Severity >= minSeverity) + .Select(f => new + { + Id = f.FindingId, + Instance = f.InstanceName, + f.RuleId, + f.Subject, + Severity = f.Severity.ToString(), + f.Title, + f.Explanation, + f.Recommendation, + Evidence = f.Evidence.Select(e => new { e.Name, Value = e.Sensitive && !includeSensitive ? "[udeladt]" : e.Value }), + f.FirstSeenAt, + f.LastSeenAt, + }); + }); + + app.MapGet("/metrics", async (InsightRepository repository, CancellationToken ct) => + Results.Text(await PrometheusAsync(repository, ct).ConfigureAwait(false), "text/plain; version=0.0.4; charset=utf-8")); + } + + private static bool IsUp(InstanceOverview i) => !i.IsFailing && i.LastSeenAt is { } seen && DateTime.UtcNow - seen < UpWindow; + + public static async Task PrometheusAsync(InsightRepository repository, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + var instances = await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false); + var findings = await repository.GetActiveFindingsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + var metrics = await repository.GetLatestMetricsAsync(TimeSpan.FromMinutes(15), cancellationToken).ConfigureAwait(false); + var update = await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false); + var text = new StringBuilder(); + + var version = typeof(IntegrationEndpoints).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0.0.0"; + Family(text, "sqlinsight_build_info", "Version of SQLInsight Pro and the rule pack in use."); + Sample(text, "sqlinsight_build_info", [("version", version), ("rulepack", update?.RulePackVersion ?? "")], 1); + + Family(text, "sqlinsight_instance_up", "1 if the last contact with the instance succeeded within 10 minutes."); + foreach (var i in instances) + { + Sample(text, "sqlinsight_instance_up", [("instance", i.Name)], IsUp(i) ? 1 : 0); + } + + Family(text, "sqlinsight_instance_last_seen_timestamp_seconds", "Unix time of the last successful contact."); + foreach (var i in instances.Where(i => i.LastSeenAt is not null)) + { + Sample(text, "sqlinsight_instance_last_seen_timestamp_seconds", [("instance", i.Name)], new DateTimeOffset(DateTime.SpecifyKind(i.LastSeenAt!.Value, DateTimeKind.Utc)).ToUnixTimeSeconds()); + } + + Family(text, "sqlinsight_instance_info", "Version and edition of each instance."); + foreach (var i in instances) + { + Sample(text, "sqlinsight_instance_info", [("instance", i.Name), ("version", i.ProductVersion ?? ""), ("level", $"{i.ProductLevel} {i.ProductUpdateLevel}".Trim()), ("edition", i.Edition ?? "")], 1); + } + + Family(text, "sqlinsight_findings", "Active findings per instance and severity."); + foreach (var i in instances) + { + foreach (var (severity, count) in new[] { ("critical", i.Critical), ("high", i.High), ("medium", i.Medium), ("low", i.Low), ("info", i.Info) }) + { + Sample(text, "sqlinsight_findings", [("instance", i.Name), ("severity", severity)], count); + } + } + + Family(text, "sqlinsight_rule_findings", "Active findings per instance and rule."); + foreach (var group in findings.GroupBy(f => (f.InstanceName, f.RuleId)).OrderBy(g => g.Key)) + { + Sample(text, "sqlinsight_rule_findings", + [("instance", group.Key.InstanceName), ("rule", group.Key.RuleId), ("severity", group.Max(f => f.Severity).ToString().ToLowerInvariant())], group.Count()); + } + + Family(text, "sqlinsight_rules_not_measurable", "Rules that could not be evaluated (version, permissions or errors)."); + foreach (var i in instances) + { + Sample(text, "sqlinsight_rules_not_measurable", [("instance", i.Name)], i.NotMeasurable); + } + + Family(text, "sqlinsight_metric", "Newest sample of each collected metric (cpu.sqlserver in %, memory.ple in s, workload.* per second, …)."); + foreach (var m in metrics) + { + Sample(text, "sqlinsight_metric", [("instance", m.Instance), ("metric", m.Metric)], m.Value); + } + + return text.ToString(); + } + + private static void Family(StringBuilder text, string name, string help) => + text.Append("# HELP ").Append(name).Append(' ').Append(help).Append('\n').Append("# TYPE ").Append(name).Append(" gauge\n"); + + private static void Sample(StringBuilder text, string name, (string Name, string Value)[] labels, double value) + { + text.Append(name).Append('{'); + for (var i = 0; i < labels.Length; i++) + { + text.Append(i == 0 ? "" : ",").Append(labels[i].Name).Append("=\"").Append(Escape(labels[i].Value)).Append('"'); + } + + text.Append("} ").Append(value.ToString("R", CultureInfo.InvariantCulture)).Append('\n'); + } + + /// Label value escaping from the Prometheus text format: backslash, double quote and newline. + public static string Escape(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal); +} diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs index 797fb03..f4a1480 100644 --- a/src/SQLInsight.Web/Program.cs +++ b/src/SQLInsight.Web/Program.cs @@ -2,8 +2,18 @@ using Microsoft.AspNetCore.Authentication.Negotiate; using Microsoft.AspNetCore.Authorization; using SQLInsight.Repository; using SQLInsight.Web; +using SQLInsight.Web.Api; using SQLInsight.Web.Components; +if (args.FirstOrDefault() == "new-api-token") +{ + var (token, hash) = ApiTokenHandler.Create(); + var name = args.ElementAtOrDefault(1) ?? "prometheus"; + Console.WriteLine($"Token (vises kun nu, gem det i det system, der skal hente data): {token}"); + Console.WriteLine($"Tilføj i appsettings.json under SQLInsight:Web:ApiTokens: \"{name}\": \"{hash}\""); + return; +} + var builder = WebApplication.CreateBuilder(args); // Runs as a Windows service with its own Kestrel web server, or behind IIS (ASP.NET Core Module), or @@ -30,13 +40,20 @@ builder.Services.AddRazorComponents(); var windowsAuth = string.Equals(web.Authentication, "Windows", StringComparison.OrdinalIgnoreCase); if (windowsAuth) { - builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme).AddNegotiate(); + // Browsers log in with Windows authentication; machines calling /api/ or /metrics may use a bearer token instead. + builder.Services.AddAuthentication("Smart") + .AddPolicyScheme("Smart", "Windows eller API-token", o => o.ForwardDefaultSelector = context => + ApiTokenHandler.AppliesTo(context.Request.Path) && context.Request.Headers.Authorization.ToString().StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? ApiTokenHandler.SchemeName + : NegotiateDefaults.AuthenticationScheme) + .AddNegotiate() + .AddScheme(ApiTokenHandler.SchemeName, null); builder.Services.AddAuthorization(options => { var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser(); if (web.AllowedGroups.Count > 0) { - policy.RequireAssertion(ctx => web.AllowedGroups.Any(ctx.User.IsInRole)); + policy.RequireAssertion(ctx => ctx.User.HasClaim(c => c.Type == ApiTokenHandler.ClaimType) || web.AllowedGroups.Any(ctx.User.IsInRole)); } options.FallbackPolicy = policy.Build(); @@ -80,6 +97,7 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit ? Results.File(System.Text.Encoding.UTF8.GetBytes(deadlock.GraphXml), "application/xml", $"deadlock-{id}.xdl") : Results.NotFound()); +IntegrationEndpoints.Map(app); app.MapRazorComponents(); // The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader. diff --git a/src/SQLInsight.Web/WebOptions.cs b/src/SQLInsight.Web/WebOptions.cs index cb31b2f..ee931e8 100644 --- a/src/SQLInsight.Web/WebOptions.cs +++ b/src/SQLInsight.Web/WebOptions.cs @@ -12,4 +12,12 @@ public sealed class WebOptions /// Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone. public string? TimeZone { get; set; } + + /// + /// Tokens for /api/ and /metrics (name → "sha256:<hex>"). Create one with SQLInsight.Web new-api-token <name>. + /// + public IDictionary ApiTokens { get; } = new Dictionary(); + + /// Include evidence marked sensitive (query text, logins, host names) in /api/v1/findings. Off by default. + public bool ApiIncludesSensitiveEvidence { get; set; } } diff --git a/tests/SQLInsight.IntegrationTests/WebApiTests.cs b/tests/SQLInsight.IntegrationTests/WebApiTests.cs new file mode 100644 index 0000000..01f08e1 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/WebApiTests.cs @@ -0,0 +1,133 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using SQLInsight.Core.Findings; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; +using SQLInsight.Rules.Engine; +using SQLInsight.Web.Api; + +namespace SQLInsight.IntegrationTests; + +/// /api/v1 and /metrics with Windows authentication switched on and bearer tokens for machines. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class WebApiTests(SqlServerFixture sql) : IAsyncLifetime +{ + private const string Token = "sqli_test-token-for-prometheus"; + + private readonly string _database = "SQLInsightApi_" + Guid.NewGuid().ToString("N")[..8]; + private WebApplicationFactory _factory = null!; + private int _port; + + public async Task InitializeAsync() + { + using (var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0)) + { + probe.Start(); + _port = ((IPEndPoint)probe.LocalEndpoint).Port; + } + + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + var repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await repository.MigrateAsync(); + var id = await repository.UpsertInstanceAsync("prod \"sql01\"", new InstanceInfo + { + ServerName = "SQL01", Version = SqlServerVersion.Parse("16.0.4175.1"), ProductLevel = "RTM", ProductUpdateLevel = "CU20", + Edition = "Enterprise Edition", EngineEdition = EngineEdition.Enterprise, + }, "SqlClient", false, DateTimeOffset.UtcNow); + await repository.SaveRuleResultsAsync(id, + [ + new RuleResult + { + RuleId = "SQLI-BLK-001", RuleTitle = "Blocking", Outcome = RuleOutcome.Findings, + Findings = + [ + new Finding + { + RuleId = "SQLI-BLK-001", Severity = Severity.High, Title = "Session 87 blokerer 14 sessioner", Explanation = "e", Recommendation = "r", + Subject = "session: 87", + Evidence = [new Evidence("Blokerede sessioner", "14"), new Evidence("Head blockers SQL", "UPDATE dbo.Kunder SET Cpr = '010101-1234'", Sensitive: true)], + }, + ], + }, + ], DateTimeOffset.UtcNow); + await sql.ExecuteAsSaAsync($"INSERT [{_database}].insight.MetricSamples (InstanceId, Metric, SampledAt, Value) VALUES ({id}, 'cpu.sqlserver', SYSUTCDATETIME(), 42.5);"); + + _factory = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); + b.UseSetting("SQLInsight:Web:Authentication", "Windows"); + b.UseSetting("SQLInsight:Web:AllowedGroups:0", @"DOMÆNE\SQL-Admins"); + b.UseSetting("SQLInsight:Web:ApiTokens:prometheus", "sha256:" + ApiTokenHandler.Hash(Token)); + b.UseSetting("urls", $"http://127.0.0.1:{_port}"); + }); + + // Negotiate needs a real server (Kestrel), not the in-memory test server. + _factory.UseKestrel(); + _factory.StartServer(); + + } + + public async Task DisposeAsync() + { + await _factory.DisposeAsync(); + await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + } + + private HttpClient Client(string? token) + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri($"http://127.0.0.1:{_port}") }); + if (token is not null) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + return client; + } + + [Fact] + public async Task Metrics_are_served_in_prometheus_format_with_a_valid_token() + { + var response = await Client(Token).GetAsync("/metrics"); + var text = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.StartsWith("text/plain", response.Content.Headers.ContentType!.MediaType, StringComparison.Ordinal); + Assert.Contains("sqlinsight_instance_up{instance=\"prod \\\"sql01\\\"\"} 1", text, StringComparison.Ordinal); + Assert.Contains("sqlinsight_findings{instance=\"prod \\\"sql01\\\"\",severity=\"high\"} 1", text, StringComparison.Ordinal); + Assert.Contains("sqlinsight_rule_findings{instance=\"prod \\\"sql01\\\"\",rule=\"SQLI-BLK-001\",severity=\"high\"} 1", text, StringComparison.Ordinal); + Assert.Contains("sqlinsight_metric{instance=\"prod \\\"sql01\\\"\",metric=\"cpu.sqlserver\"} 42.5", text, StringComparison.Ordinal); + Assert.Contains("# TYPE sqlinsight_findings gauge", text, StringComparison.Ordinal); + Assert.Contains("sqlinsight_build_info{version=\"1.0.0\"", text, StringComparison.Ordinal); + } + + [Fact] + public async Task Findings_api_leaves_out_sensitive_evidence() + { + var json = await Client(Token).GetStringAsync("/api/v1/findings?minSeverity=High"); + + using var document = JsonDocument.Parse(json); + var finding = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Equal("SQLI-BLK-001", finding.GetProperty("ruleId").GetString()); + Assert.Contains("[udeladt]", json, StringComparison.Ordinal); + Assert.DoesNotContain("010101-1234", json, StringComparison.Ordinal); + Assert.Equal(0, JsonDocument.Parse(await Client(Token).GetStringAsync("/api/v1/findings?minSeverity=Critical")).RootElement.GetArrayLength()); + } + + [Theory] + [InlineData(null, "/metrics")] + [InlineData("forkert-token", "/metrics")] + [InlineData("forkert-token", "/api/v1/instances")] + [InlineData(Token, "/")] + [InlineData(Token, "/fund")] + public async Task Without_a_valid_token_or_outside_the_api_access_is_refused(string? token, string path) + { + var response = await Client(token).GetAsync(path); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/tests/SQLInsight.Notifications.Tests/NotificationTests.cs b/tests/SQLInsight.Notifications.Tests/NotificationTests.cs index f2e5d06..e63db05 100644 --- a/tests/SQLInsight.Notifications.Tests/NotificationTests.cs +++ b/tests/SQLInsight.Notifications.Tests/NotificationTests.cs @@ -157,4 +157,49 @@ public class NotificationTests Assert.Equal("High", n.GetProperty("severity").GetString()); Assert.Equal("prod-sql01", n.GetProperty("instance").GetString()); } + + private sealed class RecordingEventLog : IEventLogWriter + { + public List<(string Source, string Message, EventLevel Level, int Id)> Events { get; } = []; + + public void Write(string source, string message, EventLevel level, int eventId) => Events.Add((source, message, level, eventId)); + } + + [Fact] + public async Task Event_log_gets_fixed_event_ids_for_scom() + { + var log = new RecordingEventLog(); + var channel = new EventLogChannel(new EventLogOptions { MinSeverity = Severity.Low }, log); + + await Dispatcher(channel).DispatchAsync( + [ + Opened(Severity.Critical), + Opened(Severity.Medium), + NotificationFactory.Resolved("prod-sql01", 42, "SQLI-CFG-001", "max server memory er ikke sat", Severity.High, null, Now), + NotificationFactory.Unreachable("legacy", "timeout", null, Now), + NotificationFactory.Recovered("legacy", null, Now), + ], default); + + Assert.Equal([1004, 1002, 2000, 3000, 3001], log.Events.Select(e => e.Id)); + Assert.Equal([EventLevel.Error, EventLevel.Warning, EventLevel.Information, EventLevel.Error, EventLevel.Information], log.Events.Select(e => e.Level)); + Assert.All(log.Events, e => Assert.Equal("SQLInsight Pro", e.Source)); + var first = log.Events[0].Message; + Assert.StartsWith("[Kritisk] prod-sql01: max server memory er ikke sat", first, StringComparison.Ordinal); + Assert.Contains("\r\nRegel: SQLI-CFG-001\r\n", first, StringComparison.Ordinal); + Assert.Contains("Link: https://sqlinsight.local/", first, StringComparison.Ordinal); + } + + [Fact] + public async Task Event_log_is_not_capped() + { + var log = new RecordingEventLog(); + var dispatcher = Dispatcher(new EventLogChannel(new EventLogOptions(), log), max: 1); + + for (var i = 0; i < 5; i++) + { + await dispatcher.DispatchAsync([Opened(Severity.High)], default); + } + + Assert.Equal(5, log.Events.Count); + } }