Review 1.3: overview leaves out removed instances and marks paused ones

The overview kept listing removed and paused instances with their last data and
counted them in the totals and alerts. Removed instances now disappear from the
overview, Alle fund, /api/v1/instances and /metrics (their history stays for
reports and the instance page); paused ones are shown as "Sat på pause" and
are left out of the totals, the urgent findings and the alerts (Prometheus gets
sqlinsight_instance_paused instead of instance_up = 0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 12:54:14 +02:00
parent 4c65248727
commit 8e1b877fda
10 changed files with 143 additions and 20 deletions

View file

@ -44,8 +44,11 @@ Kun tokenets SHA-256 gemmes i konfigurationen. Et token giver kun adgang til `/a
| `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_instance_paused` | instance | 1 for en instans, hvis overvågning er sat på pause (fra 1.3) |
| `sqlinsight_build_info` | version, rulepack | SQLInsight Pro-version og regelpakke |
Fra 1.3 er instanser, der er fjernet under *Servere og databaser*, ikke med, og instanser sat på pause kun i `sqlinsight_instance_paused`: en pause udløser ingen alarm. `GET /api/v1/instances` udelader fjernede instanser og har `paused` for de øvrige.
Eksempel på alarm i Prometheus:
```yaml

View file

@ -51,6 +51,8 @@ Servere under `SQLInsight:Instances` i agentens `appsettings.json` eller `sqlins
Ændringer i webbrugerfladen (nye servere, pause, fjernelse, valg af databaser) bruges af agenten inden for et minut, uden genstart. Ændringer i konfigurationsfilen kræver stadig en genstart af agenten.
**Pause og fjernelse.** En server på pause overvåges ikke, men vises stadig i overblikket, tydeligt markeret *Sat på pause* med de seneste data før pausen. Den tæller ikke med i overblikkets tal, i *Kritiske og høje fund*, under *Alle fund* eller i alarmer (Prometheus). En fjernet server forsvinder fra overblikket, fundlisterne og alarmerne; det indsamlede bevares og indgår stadig i rapporterne for perioden, og instansens side kan stadig åbnes. Tilføjes en server igen med samme navn, fortsætter den med sin historik.
## SQL-login-adgangskoder
En adgangskode krypteres i webbrugerfladen, i det øjeblik den modtages, med **agentens offentlige nøgle** (RSA-OAEP-SHA256 om en AES-256-GCM-nøgle). Kun agenten har den private nøgle, så hverken webbrugerfladen, API'et eller nogen, der kan læse repositoriet, kan få adgangskoden tilbage. Den vises aldrig igen og returneres aldrig af API'et (kun `hasPassword`).

View file

@ -34,6 +34,18 @@ public sealed record InstanceOverview
/// <summary>The most recent contact failed.</summary>
public bool IsFailing => LastErrorAt is { } e && (LastSeenAt is null || e > LastSeenAt);
/// <summary>Monitoring is paused in Servere og databaser (insight.ConfiguredInstances.Paused): no new findings, no alerts.</summary>
public bool Paused { get; init; }
/// <summary>
/// Removed in Servere og databaser (or from the agent's configuration file) and no longer monitored. Its history stays
/// for reports and the instance page; the overview, the finding lists and the alerts leave it out.
/// </summary>
public bool Removed { get; init; }
/// <summary>Monitored now: neither removed nor paused. Only these count in the overview's totals and in alerts.</summary>
public bool IsMonitored => !Paused && !Removed;
public Severity? WorstSeverity =>
Critical > 0 ? Severity.Critical : High > 0 ? Severity.High : Medium > 0 ? Severity.Medium : Low > 0 ? Severity.Low : Info > 0 ? Severity.Info : null;
}
@ -85,8 +97,14 @@ public sealed partial class InsightRepository
SELECT i.InstanceId, i.Name, i.ServerName, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.EngineEdition, i.ConnectionProvider,
i.UsedLegacyFallback, i.LastSeenAt, i.LastErrorAt, i.LastError,
ISNULL(f.Critical, 0) AS Critical, ISNULL(f.High, 0) AS High, ISNULL(f.Medium, 0) AS Medium, ISNULL(f.Low, 0) AS Low, ISNULL(f.Info, 0) AS Info,
ISNULL(r.NotMeasurable, 0) AS NotMeasurable, ISNULL(r.ManualChecks, 0) AS ManualChecks
ISNULL(r.NotMeasurable, 0) AS NotMeasurable, ISNULL(r.ManualChecks, 0) AS ManualChecks,
CAST(ISNULL(c.Paused, 0) AS bit) AS Paused, CAST(CASE WHEN c.Configured > 0 AND c.Active = 0 THEN 1 ELSE 0 END AS bit) AS Removed
FROM insight.Instances i
/* 1.3: paused or removed in Servere og databaser. A name without any configuration row (history from before
1.3) counts as monitored, as it did. */
OUTER APPLY (SELECT COUNT(*) AS Configured, MAX(CASE WHEN RemovedAt IS NULL THEN 1 ELSE 0 END) AS Active,
MAX(CASE WHEN RemovedAt IS NULL AND Paused = 1 THEN 1 ELSE 0 END) AS Paused
FROM insight.ConfiguredInstances WHERE Name = i.Name) c
OUTER APPLY (SELECT SUM(CASE WHEN Severity = 4 THEN 1 ELSE 0 END) AS Critical, SUM(CASE WHEN Severity = 3 THEN 1 ELSE 0 END) AS High,
SUM(CASE WHEN Severity = 2 THEN 1 ELSE 0 END) AS Medium, SUM(CASE WHEN Severity = 1 THEN 1 ELSE 0 END) AS Low,
SUM(CASE WHEN Severity = 0 THEN 1 ELSE 0 END) AS Info

View file

@ -18,7 +18,7 @@ public static class IntegrationEndpoints
{
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
api.MapGet("/instances", async (InsightRepository repository, CancellationToken ct) =>
(await repository.GetInstancesAsync(ct).ConfigureAwait(false)).Select(i => new
(await repository.GetInstancesAsync(ct).ConfigureAwait(false)).Where(i => !i.Removed).Select(i => new
{
i.Name,
i.ProductVersion,
@ -27,12 +27,14 @@ public static class IntegrationEndpoints
i.Edition,
i.LastSeenAt,
Up = IsUp(i),
i.Paused,
i.LastError,
Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info },
i.NotMeasurable,
i.ManualChecks,
}))
.WithSummary("Instanser med status og antal fund / Instances");
.WithSummary("Instanser med status og antal fund / Instances")
.WithDescription("Removed instances are left out. Paused ones are listed with paused = true; their counts are from before the pause.");
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")).RequireRead();
@ -43,9 +45,15 @@ public static class IntegrationEndpoints
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);
// Alerts are built on these series, so only instances monitored now are in them: removed ones not at all, and
// paused ones only in sqlinsight_instance_paused (a paused instance is not "down").
var all = await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false);
var instances = all.Where(i => i.IsMonitored).ToList();
var monitored = instances.Select(i => i.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
var findings = (await repository.GetActiveFindingsAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
.Where(f => monitored.Contains(f.InstanceName)).ToList();
var metrics = (await repository.GetLatestMetricsAsync(TimeSpan.FromMinutes(15), cancellationToken).ConfigureAwait(false))
.Where(m => monitored.Contains(m.Instance)).ToList();
var update = await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false);
var text = new StringBuilder();
@ -59,6 +67,12 @@ public static class IntegrationEndpoints
Sample(text, "sqlinsight_instance_up", [("instance", i.Name)], IsUp(i) ? 1 : 0);
}
Family(text, "sqlinsight_instance_paused", "1 for an instance whose monitoring is paused in Servere og databaser (left out of the other series).");
foreach (var i in all.Where(i => i.Paused && !i.Removed))
{
Sample(text, "sqlinsight_instance_paused", [("instance", i.Name)], 1);
}
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))
{

View file

@ -4,13 +4,25 @@
<PageTitle>Alle fund · SQLInsight Pro</PageTitle>
<h1>Alle aktive fund</h1>
<p class="lead">Sorteret efter alvor på tværs af alle instanser.</p>
<p class="lead">Sorteret efter alvor på tværs af alle overvågede instanser.</p>
@if (_paused.Count > 0)
{
<p class="muted">Instanser sat på pause er ikke med: @string.Join(", ", _paused). Deres seneste fund står på instansens side.</p>
}
<section class="card">
<FindingList Findings="_findings" ShowInstance="true" />
</section>
@code {
private IReadOnlyList<StoredFinding> _findings = [];
private IReadOnlyList<string> _paused = [];
protected override async Task OnInitializedAsync() => _findings = await Repository.GetActiveFindingsAsync();
protected override async Task OnInitializedAsync()
{
// Removed and paused instances are not monitored now: their findings are history, not something to act on.
var instances = await Repository.GetInstancesAsync();
var monitored = instances.Where(i => i.IsMonitored).Select(i => i.InstanceId).ToHashSet();
_paused = instances.Where(i => i.Paused && !i.Removed).Select(i => i.Name).ToList();
_findings = (await Repository.GetActiveFindingsAsync()).Where(f => monitored.Contains(f.InstanceId)).ToList();
}
}

View file

@ -18,12 +18,12 @@ else
<PrivilegeAlert Findings="_active" ShowInstance="true" />
<div class="kpis">
<div class="kpi"><span class="kpi-icon"><Icon Name="servers" /></span><div><strong>@_instances.Count</strong><span>instanser</span></div></div>
<div class="kpi sev-critical"><span class="kpi-icon"><Icon Name="alert" /></span><div><strong>@_instances.Sum(i => i.Critical)</strong><span>kritiske fund</span></div></div>
<div class="kpi sev-high"><span class="kpi-icon"><Icon Name="alert" /></span><div><strong>@_instances.Sum(i => i.High)</strong><span>høje fund</span></div></div>
<div class="kpi sev-info"><span class="kpi-icon"><Icon Name="unplug" /></span><div><strong>@_instances.Count(i => i.IsFailing)</strong><span>kan ikke nås</span></div></div>
<div class="kpi sev-info"><span class="kpi-icon"><Icon Name="gauge" /></span><div><strong>@_instances.Sum(i => i.NotMeasurable)</strong><span>regler ikke målbare</span></div></div>
<div class="kpi sev-info"><span class="kpi-icon"><Icon Name="list" /></span><div><strong>@_instances.Sum(i => i.ManualChecks)</strong><span>skal tjekkes manuelt</span></div></div>
<div class="kpi"><span class="kpi-icon"><Icon Name="servers" /></span><div><strong>@_monitored.Count</strong><span>instanser@(_paused > 0 ? $" (+{_paused} på pause)" : string.Empty)</span></div></div>
<div class="kpi sev-critical"><span class="kpi-icon"><Icon Name="alert" /></span><div><strong>@_monitored.Sum(i => i.Critical)</strong><span>kritiske fund</span></div></div>
<div class="kpi sev-high"><span class="kpi-icon"><Icon Name="alert" /></span><div><strong>@_monitored.Sum(i => i.High)</strong><span>høje fund</span></div></div>
<div class="kpi sev-info"><span class="kpi-icon"><Icon Name="unplug" /></span><div><strong>@_monitored.Count(i => i.IsFailing)</strong><span>kan ikke nås</span></div></div>
<div class="kpi sev-info"><span class="kpi-icon"><Icon Name="gauge" /></span><div><strong>@_monitored.Sum(i => i.NotMeasurable)</strong><span>regler ikke målbare</span></div></div>
<div class="kpi sev-info"><span class="kpi-icon"><Icon Name="list" /></span><div><strong>@_monitored.Sum(i => i.ManualChecks)</strong><span>skal tjekkes manuelt</span></div></div>
</div>
<section class="card">
@ -33,11 +33,15 @@ else
<tr><th>Status</th><th>Instans</th><th>Version</th><th>Fund</th><th>Ikke målbar</th><th>Sidst set</th></tr>
</thead>
<tbody>
@foreach (var i in _instances.OrderByDescending(i => i.IsFailing).ThenByDescending(i => i.WorstSeverity ?? (Severity)(-1)).ThenBy(i => i.Name))
@foreach (var i in _instances.OrderBy(i => i.Paused).ThenByDescending(i => i.IsFailing).ThenByDescending(i => i.WorstSeverity ?? (Severity)(-1)).ThenBy(i => i.Name))
{
<tr>
<tr class="@(i.Paused ? "paused" : string.Empty)">
<td>
@if (i.IsFailing)
@if (i.Paused)
{
<span class="badge sev-info" title="Overvågningen er sat på pause under Servere og databaser. Der kommer ingen nye fund eller alarmer, og tallene tæller ikke med i overblikket.">Sat på pause</span>
}
else if (i.IsFailing)
{
<span class="badge sev-critical" title="@i.LastError">Fejler</span>
}
@ -49,6 +53,10 @@ else
<td><a href="/instanser/@Uri.EscapeDataString(i.Name)"><strong>@i.Name</strong></a><div class="muted">@i.ServerName</div></td>
<td>@(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion, i.EngineEdition))<div class="muted">@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel</div></td>
<td><div class="counts">
@if (i.Paused)
{
<span class="muted">sidste data før pausen</span>
}
@if (i.Critical > 0) { <SeverityBadge Severity="Severity.Critical" Count="i.Critical" /> }
@if (i.High > 0) { <SeverityBadge Severity="Severity.High" Count="i.High" /> }
@if (i.Medium > 0) { <SeverityBadge Severity="Severity.Medium" Count="i.Medium" /> }
@ -70,13 +78,20 @@ else
@code {
private IReadOnlyList<InstanceOverview> _instances = [];
private IReadOnlyList<InstanceOverview> _monitored = [];
private int _paused;
private IReadOnlyList<StoredFinding> _active = [];
private IReadOnlyList<StoredFinding> _urgent = [];
protected override async Task OnInitializedAsync()
{
_instances = await Repository.GetInstancesAsync();
_active = await Repository.GetActiveFindingsAsync();
// Removed instances are left out (their history stays for reports); paused ones are shown, marked, but do not
// count in the totals, the urgent findings or the privilege alert.
_instances = (await Repository.GetInstancesAsync()).Where(i => !i.Removed).ToList();
_monitored = _instances.Where(i => i.IsMonitored).ToList();
_paused = _instances.Count(i => i.Paused);
var monitoredIds = _monitored.Select(i => i.InstanceId).ToHashSet();
_active = (await Repository.GetActiveFindingsAsync()).Where(f => monitoredIds.Contains(f.InstanceId)).ToList();
_urgent = _active.Where(f => f.Severity >= Severity.High).ToList();
}
}

View file

@ -24,9 +24,18 @@
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/fejllog">Fejllog</a>
</p>
@if (_instance.Removed)
{
<div class="alert warn">Instansen er fjernet under <a href="/opsaetning/instanser">Servere og databaser</a> og overvåges ikke længere. Det, der blev indsamlet, vises her og i rapporterne.</div>
}
else if (_instance.Paused)
{
<div class="alert warn"><strong>Sat på pause.</strong> Overvågningen er sat på pause under <a href="/opsaetning/instanser">Servere og databaser</a>. Det, der står her, er de seneste data før pausen; der kommer ingen nye fund eller alarmer.</div>
}
<PrivilegeAlert Findings="_findings" />
@if (_instance.IsFailing)
@if (_instance.IsFailing && _instance.IsMonitored)
{
<div class="alert">
<strong>Seneste forsøg på at nå instansen fejlede</strong> (@Display.Time(_instance.LastErrorAt)):

View file

@ -237,3 +237,4 @@ button.button:hover { filter: brightness(1.08); }
button.button.secondary { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); }
button.button.danger { background: var(--critical); }
tr.muted td { color: var(--text-2); }
.instances tr.paused td { opacity: .7; }

View file

@ -158,6 +158,30 @@ public class InstanceConfigurationRepositoryTests(SqlServerFixture sql) : IAsync
Assert.Equal(2, (await _repository.GetConfiguredInstancesAsync()).Count);
}
[Fact]
public async Task The_overview_knows_which_instances_are_paused_or_removed()
{
var now = DateTimeOffset.UtcNow;
var paused = await _repository.AddConfiguredInstanceAsync(Instance("paused-sql"), null, now);
var removed = await _repository.AddConfiguredInstanceAsync(Instance("removed-sql"), null, now);
foreach (var name in new[] { "paused-sql", "removed-sql", "before-1.3" })
{
await _repository.RecordInstanceErrorAsync(name, "x", now);
}
await _repository.SetInstancePausedAsync(paused, true, null, now);
await _repository.RemoveConfiguredInstanceAsync(removed, null, now);
var overview = (await _repository.GetInstancesAsync()).ToDictionary(i => i.Name);
Assert.Equal((true, false), (overview["paused-sql"].Paused, overview["paused-sql"].Removed));
Assert.Equal((false, true), (overview["removed-sql"].Paused, overview["removed-sql"].Removed));
Assert.True(overview["before-1.3"].IsMonitored);
// Added again under the same name: monitored again, with its history.
await _repository.AddConfiguredInstanceAsync(Instance("removed-sql"), null, now);
Assert.True((await _repository.GetInstanceAsync("removed-sql"))!.IsMonitored);
}
[Fact]
public async Task A_request_is_claimed_once_and_stale_ones_are_failed_with_a_reason()
{

View file

@ -68,6 +68,31 @@ public sealed partial class InstanceSetupWebTests(SqlServerFixture sql) : IAsync
private static int IdFrom(HttpResponseMessage response) =>
int.Parse(Regex.Match(response.Headers.Location!.OriginalString, @"/opsaetning/instanser/(\d+)").Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
[Fact]
public async Task Overview_marks_paused_servers_and_leaves_out_removed_ones_and_so_do_the_alerts()
{
var now = DateTimeOffset.UtcNow;
var paused = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("paused-sql", "sql05", null, "Windows", null, null, null, "Mandatory", false, false), "anna", now);
var removed = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("removed-sql", "sql06", null, "Windows", null, null, null, "Mandatory", false, false), "anna", now);
await _repository.RecordInstanceErrorAsync("paused-sql", "kan ikke nås", now);
await _repository.RecordInstanceErrorAsync("removed-sql", "kan ikke nås", now);
await _repository.SetInstancePausedAsync(paused, true, "anna", now);
await _repository.RemoveConfiguredInstanceAsync(removed, "anna", now);
var home = await Client().GetStringAsync("/");
var metrics = await Client().GetStringAsync("/metrics");
Assert.Contains("paused-sql", home, StringComparison.Ordinal);
Assert.Contains("Sat på pause", home, StringComparison.Ordinal);
Assert.DoesNotContain("removed-sql", home, StringComparison.Ordinal);
Assert.Contains("sqlinsight_instance_paused{instance=\"paused-sql\"} 1", metrics, StringComparison.Ordinal);
Assert.DoesNotContain("sqlinsight_instance_up{instance=\"paused-sql\"}", metrics, StringComparison.Ordinal);
Assert.DoesNotContain("removed-sql", metrics, StringComparison.Ordinal);
// The history is still there.
Assert.Contains("fjernet", await Client().GetStringAsync("/instanser/removed-sql"), StringComparison.Ordinal);
}
[Fact]
public async Task Page_lists_servers_from_the_configuration_file_and_the_web_and_the_menu_links_to_it()
{