diff --git a/docs/integration.md b/docs/integration.md index 95b6375..c5c659d 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -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 diff --git a/docs/servere-og-databaser.md b/docs/servere-og-databaser.md index 9df5fe1..2c091d8 100644 --- a/docs/servere-og-databaser.md +++ b/docs/servere-og-databaser.md @@ -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`). diff --git a/src/SQLInsight.Repository/InsightRepository.Read.cs b/src/SQLInsight.Repository/InsightRepository.Read.cs index 1c974b6..d3ba057 100644 --- a/src/SQLInsight.Repository/InsightRepository.Read.cs +++ b/src/SQLInsight.Repository/InsightRepository.Read.cs @@ -34,6 +34,18 @@ public sealed record InstanceOverview /// The most recent contact failed. public bool IsFailing => LastErrorAt is { } e && (LastSeenAt is null || e > LastSeenAt); + /// Monitoring is paused in Servere og databaser (insight.ConfiguredInstances.Paused): no new findings, no alerts. + public bool Paused { get; init; } + + /// + /// 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. + /// + public bool Removed { get; init; } + + /// Monitored now: neither removed nor paused. Only these count in the overview's totals and in alerts. + 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 diff --git a/src/SQLInsight.Web/Api/IntegrationEndpoints.cs b/src/SQLInsight.Web/Api/IntegrationEndpoints.cs index f5c3281..8b74eb8 100644 --- a/src/SQLInsight.Web/Api/IntegrationEndpoints.cs +++ b/src/SQLInsight.Web/Api/IntegrationEndpoints.cs @@ -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 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)) { diff --git a/src/SQLInsight.Web/Components/Pages/AllFindings.razor b/src/SQLInsight.Web/Components/Pages/AllFindings.razor index 77b2acb..7a8fafb 100644 --- a/src/SQLInsight.Web/Components/Pages/AllFindings.razor +++ b/src/SQLInsight.Web/Components/Pages/AllFindings.razor @@ -4,13 +4,25 @@ Alle fund · SQLInsight Pro

Alle aktive fund

-

Sorteret efter alvor på tværs af alle instanser.

+

Sorteret efter alvor på tværs af alle overvågede instanser.

+@if (_paused.Count > 0) +{ +

Instanser sat på pause er ikke med: @string.Join(", ", _paused). Deres seneste fund står på instansens side.

+}
@code { private IReadOnlyList _findings = []; + private IReadOnlyList _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(); + } } diff --git a/src/SQLInsight.Web/Components/Pages/Home.razor b/src/SQLInsight.Web/Components/Pages/Home.razor index 01f5f6d..2bc5273 100644 --- a/src/SQLInsight.Web/Components/Pages/Home.razor +++ b/src/SQLInsight.Web/Components/Pages/Home.razor @@ -18,12 +18,12 @@ else
-
@_instances.Countinstanser
-
@_instances.Sum(i => i.Critical)kritiske fund
-
@_instances.Sum(i => i.High)høje fund
-
@_instances.Count(i => i.IsFailing)kan ikke nås
-
@_instances.Sum(i => i.NotMeasurable)regler ikke målbare
-
@_instances.Sum(i => i.ManualChecks)skal tjekkes manuelt
+
@_monitored.Countinstanser@(_paused > 0 ? $" (+{_paused} på pause)" : string.Empty)
+
@_monitored.Sum(i => i.Critical)kritiske fund
+
@_monitored.Sum(i => i.High)høje fund
+
@_monitored.Count(i => i.IsFailing)kan ikke nås
+
@_monitored.Sum(i => i.NotMeasurable)regler ikke målbare
+
@_monitored.Sum(i => i.ManualChecks)skal tjekkes manuelt
@@ -33,11 +33,15 @@ else StatusInstansVersionFundIkke målbarSidst set - @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)) { - + - @if (i.IsFailing) + @if (i.Paused) + { + Sat på pause + } + else if (i.IsFailing) { Fejler } @@ -49,6 +53,10 @@ else @i.Name
@i.ServerName
@(i.ProductVersion is null ? "–" : Display.Release(i.ProductVersion, i.EngineEdition))
@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel
+ @if (i.Paused) + { + sidste data før pausen + } @if (i.Critical > 0) { } @if (i.High > 0) { } @if (i.Medium > 0) { } @@ -70,13 +78,20 @@ else @code { private IReadOnlyList _instances = []; + private IReadOnlyList _monitored = []; + private int _paused; private IReadOnlyList _active = []; private IReadOnlyList _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(); } } diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index 2b32168..0c13a89 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -24,9 +24,18 @@ Fejllog

+@if (_instance.Removed) +{ +
Instansen er fjernet under Servere og databaser og overvåges ikke længere. Det, der blev indsamlet, vises her og i rapporterne.
+} +else if (_instance.Paused) +{ +
Sat på pause. Overvågningen er sat på pause under Servere og databaser. Det, der står her, er de seneste data før pausen; der kommer ingen nye fund eller alarmer.
+} + -@if (_instance.IsFailing) +@if (_instance.IsFailing && _instance.IsMonitored) {
Seneste forsøg på at nå instansen fejlede (@Display.Time(_instance.LastErrorAt)): diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css index 49ee481..697a40c 100644 --- a/src/SQLInsight.Web/wwwroot/app.css +++ b/src/SQLInsight.Web/wwwroot/app.css @@ -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; } diff --git a/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs b/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs index c0e0a6f..a67ea95 100644 --- a/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs +++ b/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs @@ -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() { diff --git a/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs b/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs index 1cf5271..226f91a 100644 --- a/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs +++ b/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs @@ -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() {