Fase 4: integration with Prometheus, Grafana, SCOM and scripts
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions

Read-only JSON API (/api/v1/instances, /api/v1/findings) and a
Prometheus endpoint (/metrics) in the web UI, with bearer tokens for
machines (only their SHA-256 is configured; a token only opens the API,
never the pages; sensitive evidence left out unless enabled). A
Grafana dashboard and Prometheus scrape example. A Windows Event Log
notification channel with fixed event ids for SCOM, exempt from the
hourly cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 09:56:01 +00:00
parent 19fbb02045
commit 0e2146ef6d
No known key found for this signature in database
17 changed files with 1374 additions and 3 deletions

View file

@ -20,6 +20,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" /> <PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />

View file

@ -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
}
}
]
}

View file

@ -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"]

105
docs/integration.md Normal file
View file

@ -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.

View file

@ -58,6 +58,11 @@ builder.Services.AddSingleton(sp =>
channels.Add(new WebhookChannel(options.Webhook, http.CreateClient("notifications"))); 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<TimeProvider>(), sp.GetRequiredService<ILogger<NotificationDispatcher>>()); return new NotificationDispatcher(channels, options, sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<ILogger<NotificationDispatcher>>());
}); });
builder.Services.Configure<SQLInsight.Agent.Hub.HubOptions>(builder.Configuration.GetSection(SQLInsight.Agent.Hub.HubOptions.SectionName)); builder.Services.Configure<SQLInsight.Agent.Hub.HubOptions>(builder.Configuration.GetSection(SQLInsight.Agent.Hub.HubOptions.SectionName));

View file

@ -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);
}
/// <summary>
/// Writes each notification to the Windows Event Log (Application) with a fixed event id:
/// <list type="table">
/// <item><term>1000–1004</term><description>New finding: 1000 Info, 1001 Low, 1002 Medium, 1003 High, 1004 Critical</description></item>
/// <item><term>2000</term><description>Finding resolved</description></item>
/// <item><term>3000 / 3001</term><description>Instance unreachable / responding again</description></item>
/// </list>
/// </summary>
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<Notification> 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),
};
}
/// <summary>Headline first, then one "Name: value" per line, so SCOM can parse it.</summary>
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();
}
}

View file

@ -40,4 +40,7 @@ public interface INotificationChannel
Severity MinSeverity { get; } Severity MinSeverity { get; }
Task SendAsync(IReadOnlyList<Notification> notifications, CancellationToken cancellationToken); Task SendAsync(IReadOnlyList<Notification> notifications, CancellationToken cancellationToken);
/// <summary>Exempt from the hourly cap: local channels that monitoring systems read, where a dropped event is a missed alert.</summary>
bool Unlimited => false;
} }

View file

@ -19,7 +19,7 @@ public sealed partial class NotificationDispatcher(IEnumerable<INotificationChan
foreach (var channel in channels) foreach (var channel in channels)
{ {
var relevant = notifications.Where(n => IsRelevant(channel, n)).ToList(); var relevant = notifications.Where(n => 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; continue;
} }

View file

@ -18,6 +18,21 @@ public sealed class NotificationOptions
public EmailOptions Email { get; set; } = new(); public EmailOptions Email { get; set; } = new();
public TeamsOptions Teams { get; set; } = new(); public TeamsOptions Teams { get; set; } = new();
public WebhookOptions Webhook { get; set; } = new(); public WebhookOptions Webhook { get; set; } = new();
public EventLogOptions EventLog { get; set; } = new();
}
/// <summary>
/// Windows Event Log, for System Center Operations Manager and other tools that watch the event log. Event ids are
/// fixed (see <see cref="EventLogChannel"/>), so rules in SCOM can be built on them.
/// </summary>
public sealed class EventLogOptions
{
public bool Enabled { get; set; }
/// <summary>Event source; must be registered once by an administrator (New-EventLog).</summary>
public string Source { get; set; } = "SQLInsight Pro";
public Severity MinSeverity { get; set; } = Severity.Medium;
} }
public sealed class EmailOptions public sealed class EmailOptions

View file

@ -7,6 +7,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" /> <PackageReference Include="MailKit" />
<PackageReference Include="System.Diagnostics.EventLog" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -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
{
/// <summary>The newest sample of each metric per instance, if it is no older than <paramref name="maxAge"/>.</summary>
public async Task<IReadOnlyList<LatestMetric>> GetLatestMetricsAsync(TimeSpan maxAge, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<LatestMetric>("""
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();
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IOptions<WebOptions> web)
: AuthenticationHandler<AuthenticationSchemeOptions>(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<AuthenticateResult> 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)));
/// <summary>A new random token and the line to put in appsettings.json.</summary>
public static (string Token, string Hash) Create()
{
var token = "sqli_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
return (token, "sha256:" + Hash(token));
}
}

View file

@ -0,0 +1,142 @@
using System.Globalization;
using System.Reflection;
using System.Text;
using SQLInsight.Core.Findings;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>
/// Read-only integration: JSON for scripts and other tools, and the Prometheus text format for Prometheus/Grafana.
/// </summary>
public static class IntegrationEndpoints
{
/// <summary>An instance counts as up when the last contact succeeded and is this recent.</summary>
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<WebOptions> 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<string> 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<AssemblyInformationalVersionAttribute>()?.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');
}
/// <summary>Label value escaping from the Prometheus text format: backslash, double quote and newline.</summary>
public static string Escape(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal);
}

View file

@ -2,8 +2,18 @@ using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using SQLInsight.Repository; using SQLInsight.Repository;
using SQLInsight.Web; using SQLInsight.Web;
using SQLInsight.Web.Api;
using SQLInsight.Web.Components; 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); var builder = WebApplication.CreateBuilder(args);
// Runs as a Windows service with its own Kestrel web server, or behind IIS (ASP.NET Core Module), or // 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); var windowsAuth = string.Equals(web.Authentication, "Windows", StringComparison.OrdinalIgnoreCase);
if (windowsAuth) 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<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, ApiTokenHandler>(ApiTokenHandler.SchemeName, null);
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization(options =>
{ {
var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser(); var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser();
if (web.AllowedGroups.Count > 0) 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(); 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.File(System.Text.Encoding.UTF8.GetBytes(deadlock.GraphXml), "application/xml", $"deadlock-{id}.xdl")
: Results.NotFound()); : Results.NotFound());
IntegrationEndpoints.Map(app);
app.MapRazorComponents<App>(); app.MapRazorComponents<App>();
// The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader. // The web UI only reads. The agent owns the schema and migrates it, so the UI's login needs no more than db_datareader.

View file

@ -12,4 +12,12 @@ public sealed class WebOptions
/// <summary>Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone.</summary> /// <summary>Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone.</summary>
public string? TimeZone { get; set; } public string? TimeZone { get; set; }
/// <summary>
/// Tokens for /api/ and /metrics (name → "sha256:&lt;hex&gt;"). Create one with <c>SQLInsight.Web new-api-token &lt;name&gt;</c>.
/// </summary>
public IDictionary<string, string> ApiTokens { get; } = new Dictionary<string, string>();
/// <summary>Include evidence marked sensitive (query text, logins, host names) in /api/v1/findings. Off by default.</summary>
public bool ApiIncludesSensitiveEvidence { get; set; }
} }

View file

@ -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;
/// <summary>/api/v1 and /metrics with Windows authentication switched on and bearer tokens for machines.</summary>
[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<Program> _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<Program>().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);
}
}

View file

@ -157,4 +157,49 @@ public class NotificationTests
Assert.Equal("High", n.GetProperty("severity").GetString()); Assert.Equal("High", n.GetProperty("severity").GetString());
Assert.Equal("prod-sql01", n.GetProperty("instance").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);
}
} }