diff --git a/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs b/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs index ec441f4..7b08d2d 100644 --- a/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs +++ b/src/SQLInsight.Core/SyntheticChecks/SyntheticCheckDefinitions.cs @@ -162,6 +162,9 @@ public static class SyntheticCheckLimits public static class SyntheticCheckValidation { + /// The longest HTTP address a check may have. + public const int MaxUrlLength = 2000; + /// Why the check cannot be saved (Danish), or null. : the check is tied to a Windows server. public static string? Validate(string? name, string? kind, SyntheticCheckSettings settings, int intervalSeconds, int timeoutSeconds, bool hasServer) { @@ -245,6 +248,11 @@ public static class SyntheticCheckValidation return "Angiv en adresse, der starter med http:// eller https://."; } + if (url.AbsoluteUri.Length > MaxUrlLength || url.OriginalString.Length > MaxUrlLength) + { + return $"Adressen må højst være {MaxUrlLength} tegn."; + } + if (!string.IsNullOrEmpty(url.UserInfo)) { return "Adressen må ikke indeholde brugernavn eller adgangskode."; diff --git a/src/SQLInsight.Web/Components/Pages/ServerFindingDetail.razor b/src/SQLInsight.Web/Components/Pages/ServerFindingDetail.razor index a0e6e3e..060e29f 100644 --- a/src/SQLInsight.Web/Components/Pages/ServerFindingDetail.razor +++ b/src/SQLInsight.Web/Components/Pages/ServerFindingDetail.razor @@ -14,14 +14,30 @@

@if (_finding.Scope == FindingScope.Server) { - Windows-servere / @_finding.ScopeName + Windows-servere / } else { - Syntetiske tjek / @_finding.ScopeName + Syntetiske tjek / + } + @if (_finding.ScopeRemoved) + { + @_finding.ScopeName + } + else + { + @_finding.ScopeName } / @_finding.RuleId

+@if (_finding.ResolvedAt is null && _finding.ScopeRemoved) +{ +

@(_finding.Scope == FindingScope.Server ? "Serveren" : "Tjekket") er fjernet og overvåges ikke længere. Fundet er historik og løses inden for en time.

+} +else if (_finding.ResolvedAt is null && _finding.ScopePaused) +{ +

@(_finding.Scope == FindingScope.Server ? "Serveren" : "Tjekket") er sat på pause, så fundet er ikke med blandt de aktive fund og løses inden for en time.

+}

@_finding.Title

@if (_finding.ResolvedAt is { } resolved) diff --git a/src/SQLInsight.Web/Components/Pages/SyntheticCheckDetail.razor b/src/SQLInsight.Web/Components/Pages/SyntheticCheckDetail.razor index b28c60e..64508b4 100644 --- a/src/SQLInsight.Web/Components/Pages/SyntheticCheckDetail.razor +++ b/src/SQLInsight.Web/Components/Pages/SyntheticCheckDetail.razor @@ -25,7 +25,7 @@

@_check.Name

@SyntheticCheckKind.Display(_check.Kind) · @_settings.Target(_check.Kind) · - · hvert @(_check.IntervalSeconds / 60). minut, timeout @_check.TimeoutSeconds s + · @ServerSetup.IntervalText(_check.IntervalSeconds), timeout @_check.TimeoutSeconds s @if (_check.ServerId is { } serverId) { · @_check.ServerName@(_settings.ServiceName is { } svc ? " (" + svc + ")" : string.Empty) @@ -92,7 +92,11 @@ } } - @if (_mayChange) + @if (_mayChange && _check.Paused) + { +

Tjekket er sat på pause og køres ikke, heller ikke på bestilling. Genoptag det under Indstillinger.

+ } + else if (_mayChange) {
} @@ -188,7 +192,10 @@ .Where(r => r.Success).OrderBy(r => r.RunAt).Select(r => new MetricPoint(r.RunAt, r.DurationMs)).ToList(); _findings = await Repository.GetServerFindingsAsync(FindingScope.Check, Id); _servers = await Repository.GetWindowsServersAsync(); - _run = Koersel is not null || Tilfoejet == 1 ? await Repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, Id) : null; + // The run asked for (?koersel=), if it is about this check; after adding, the first run. + _run = Koersel is { } run + ? await Repository.GetAgentRequestAsync(run) is { Kind: ServerRequestKind.SyntheticCheckRun } request && request.TargetId == Id ? request : null + : Tilfoejet == 1 ? await Repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, Id) : null; _mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value); } } diff --git a/src/SQLInsight.Web/Components/Pages/SyntheticChecksPage.razor b/src/SQLInsight.Web/Components/Pages/SyntheticChecksPage.razor index 01faeda..62a8f54 100644 --- a/src/SQLInsight.Web/Components/Pages/SyntheticChecksPage.razor +++ b/src/SQLInsight.Web/Components/Pages/SyntheticChecksPage.razor @@ -19,7 +19,7 @@ } @if (!_mayChange) { -

Du kan se tjekkene, men kun administratorer kan tilføje og ændre dem (SQLInsight:Web:AdminGroups).

+

Du kan se tjekkene, men kun administratorer kan tilføje og ændre dem. @InstanceSetup.WhoMayChangeText(Options.Value)

}
diff --git a/src/SQLInsight.Web/Components/Pages/WindowsServerDetail.razor b/src/SQLInsight.Web/Components/Pages/WindowsServerDetail.razor index a452d89..af4a085 100644 --- a/src/SQLInsight.Web/Components/Pages/WindowsServerDetail.razor +++ b/src/SQLInsight.Web/Components/Pages/WindowsServerDetail.razor @@ -127,7 +127,7 @@ } -

Prognosen er en ret linje gennem SQLInsights egne målinger af ledig plads de seneste @(_forecastDays) dage, siden drevet sidst skiftede størrelse. Svinger pladsen meget, gives ingen prognose. Målt @Display.Time(_disks.CapturedAt.UtcDateTime).

+

Prognosen er en ret linje gennem SQLInsights egne målinger af ledig plads @ForecastPeriod, siden drevet sidst skiftede størrelse. Svinger pladsen meget, gives ingen prognose. Målt @Display.Time(_disks.CapturedAt.UtcDateTime).

}
@@ -334,7 +334,12 @@ }

Script, der giver rettighederne

-

Kør det som administrator på @_server.Host. Det giver agentens konto læseadgang (Performance Monitor Users, Event Log Readers, Distributed COM Users, Remote Management Users, WMI Remote Enable på root\cimv2 og TaskScheduler, læseret på Service Control Manager) og intet andet. SQLInsight kører det aldrig selv. -Remove fjerner rettighederne igen, og -EnableFirewall slår Windows' egne firewallregler til.

+

Kør det som administrator på @_server.Host – først med -WhatIf, der viser, hvad det vil gøre, uden at ændre noget, og gerne først på en server, der ikke er i produktion. Det giver agentens konto læseadgang (Performance Monitor Users, Event Log Readers, Distributed COM Users, WMI Enable Account og Remote Enable på root\cimv2 og TaskScheduler, læseret på Service Control Manager) og intet andet, og det gemmer de oprindelige indstillinger, før det ændrer noget. SQLInsight kører det aldrig selv.

+ @if (_grant.Script is { } script) {

Konto: @_grant.Account · Hent som fil

@@ -423,7 +428,15 @@ private WindowsServerTestResult? _testResult; private (string? Account, string? Script, string? Note) _grant; private bool _mayChange; - private int _forecastDays = 14; + + /// + /// The history the forecasts are computed from, as the agent used it (SQLInsight:Servers:Disks:ForecastHistoryDays, + /// capped by what has been collected): the longest history among the volumes. + /// + private string ForecastPeriod => + _disks?.Forecasts.Count > 0 && _disks.Forecasts.Max(f => f.HistoryDays) is var days and >= 1 + ? "de seneste " + Display.Number(Math.Round(days), 0) + " dage" + : "de seneste dage"; protected override async Task OnInitializedAsync() { diff --git a/src/SQLInsight.Web/Components/Pages/WindowsServersPage.razor b/src/SQLInsight.Web/Components/Pages/WindowsServersPage.razor index 909488a..50c26d1 100644 --- a/src/SQLInsight.Web/Components/Pages/WindowsServersPage.razor +++ b/src/SQLInsight.Web/Components/Pages/WindowsServersPage.razor @@ -20,7 +20,7 @@ } @if (!_mayChange) { -

Du kan se serverne, men kun administratorer kan tilføje og ændre dem (SQLInsight:Web:AdminGroups).

+

Du kan se serverne, men kun administratorer kan tilføje og ændre dem. @InstanceSetup.WhoMayChangeText(Options.Value)

}
@@ -36,9 +36,9 @@ @foreach (var s in _servers) { - var disks = _disks.GetValueOrDefault(s.ServerId); + var overview = _overviews.GetValueOrDefault(s.ServerId); var counts = _counts.GetValueOrDefault((FindingScope.Server, s.ServerId)); - var latest = _latest.GetValueOrDefault(s.ServerId); + var latest = overview?.Latest; @s.Name
@s.Host
@ServerRoles.Display(s.Role)@(s.IsSqlHost ? " · fra SQL Server-instanser" : string.Empty) @@ -46,14 +46,14 @@ @(latest?.CpuPercent is { } cpu ? Display.Number(cpu) + " %" : "–") @(latest?.AvailableMb is { } mb ? Display.Number(mb / 1024, 1) + " GB" : "–") - @if (disks is null) + @if (overview?.Disks is null) { – } else { - var worst = disks.Disks.OrderBy(d => d.FreePercent).FirstOrDefault(); - var soonest = disks.Forecasts.Where(f => f.DaysUntilFull is not null).OrderBy(f => f.DaysUntilFull).FirstOrDefault(); + var worst = overview.WorstDisk; + var soonest = overview.SoonestFull; @if (worst is not null) { @worst.Volume @Display.Number(worst.FreePercent) % ledig @@ -86,6 +86,10 @@

Aktive fund på servere og syntetiske tjek

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

Servere og tjek sat på pause er ikke med: @string.Join(", ", _paused). Deres seneste fund står på deres egen side.

+ }
@@ -122,28 +126,19 @@ private IReadOnlyList _servers = []; private IReadOnlyList _findings = []; private IReadOnlyDictionary<(string, int), IReadOnlyDictionary> _counts = new Dictionary<(string, int), IReadOnlyDictionary>(); - private readonly Dictionary _disks = []; - private readonly Dictionary _latest = []; + private IReadOnlyDictionary _overviews = new Dictionary(); + private IReadOnlyList _paused = []; private bool _mayChange; protected override async Task OnInitializedAsync() { + // Paused (and removed) servers and checks are not looked at now: their findings are not in the active list. _servers = await Repository.GetWindowsServersAsync(); _findings = await Repository.GetServerFindingsAsync(); _counts = await Repository.GetServerFindingCountsAsync(); - foreach (var s in _servers.Where(s => !s.Paused)) - { - if (await Repository.GetServerSnapshotAsync(s.ServerId, ServerArea.Disks) is { } disks) - { - _disks[s.ServerId] = disks; - } - - if ((await Repository.GetServerPerformanceAsync(s.ServerId, DateTimeOffset.UtcNow.AddMinutes(-10))).LastOrDefault() is { } latest) - { - _latest[s.ServerId] = latest; - } - } - + _overviews = await Repository.GetWindowsServerOverviewsAsync(DateTimeOffset.UtcNow.AddMinutes(-10)); + _paused = _servers.Where(s => s.Paused).Select(s => s.Name) + .Concat((await Repository.GetSyntheticChecksAsync()).Where(c => c.Paused).Select(c => c.Name)).ToList(); _mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value); } } diff --git a/src/SQLInsight.Web/Components/Shared/CheckForm.razor b/src/SQLInsight.Web/Components/Shared/CheckForm.razor index ebe52ef..39757ec 100644 --- a/src/SQLInsight.Web/Components/Shared/CheckForm.razor +++ b/src/SQLInsight.Web/Components/Shared/CheckForm.razor @@ -21,7 +21,15 @@ } - + @if (Check is { IntervalSeconds: var seconds } && seconds % 60 != 0) + { + @* Set to a part of a minute through the API: kept in seconds, not rounded on the next save. *@ + + } + else + { + + }
diff --git a/src/SQLInsight.Web/ServerSetupEndpoints.cs b/src/SQLInsight.Web/ServerSetupEndpoints.cs index da6994c..f4c74b6 100644 --- a/src/SQLInsight.Web/ServerSetupEndpoints.cs +++ b/src/SQLInsight.Web/ServerSetupEndpoints.cs @@ -43,8 +43,28 @@ public static class ServerSetupEndpoints return InstanceSetup.MayChange(http.User, options) ? null : Results.Redirect(WithMessage(back, "fejl", ServerSetup.NotAdminText)); } - private static int? Int(IFormCollection form, string name) => - int.TryParse(form[name].ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null; + /// A whole number from the form: null when the field is empty, Bad when it is not a number. + private static (int? Value, bool Bad) Number(IFormCollection form, string name) + { + var text = form[name].ToString().Trim(); + if (text.Length == 0) + { + return (null, false); + } + + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? (value, false) : (null, true); + } + + /// The check form's number fields and what to say when one is not a number (as for the instances' port). + private static readonly (string Field, string Message)[] NumberFields = + [ + ("intervalMinutes", "Intervallet skal være et helt antal minutter."), + ("intervalSeconds", "Intervallet skal være et helt antal sekunder."), + ("timeoutSeconds", "Timeout skal være et helt antal sekunder."), + ("serverId", "Vælg Windows-serveren på listen."), + ("port", "Porten skal være et tal."), + ("warnMilliseconds", "Grænsen for langsomt svar skal være et tal (millisekunder)."), + ]; private static ServerInput ServerInput(IFormCollection form) => new( form["name"].ToString(), @@ -135,18 +155,32 @@ public static class ServerSetupEndpoints return Results.File(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(script)).ToArray(), "text/plain; charset=utf-8", "grant-windows-server.ps1"); } - /// The check form: every kind's fields are there; only the chosen kind's are kept (). - private static CheckInput CheckInput(IFormCollection form) + /// + /// The check form: every kind's fields are there; only the chosen kind's are kept (). + /// A number field that is not a number is refused rather than ignored. The interval is in minutes, or in seconds when + /// it was set to a part of a minute through the API (the form then keeps it in seconds). + /// + private static (CheckInput? Input, string? Error) CheckInput(IFormCollection form) { - var minutes = Int(form, "intervalMinutes"); - return new CheckInput( + foreach (var (field, message) in NumberFields) + { + if (Number(form, field).Bad) + { + return (null, message); + } + } + + int? Int(string name) => Number(form, name).Value; + var interval = Int("intervalSeconds") + ?? (Int("intervalMinutes") is { } minutes ? Math.Clamp(minutes, 0, (SyntheticCheckLimits.MaxIntervalSeconds / 60) + 1) * 60 : null); + return (new CheckInput( form["name"].ToString(), form["kind"].ToString() is { Length: > 0 } kind ? kind : null, - Int(form, "serverId") is > 0 and var server ? server : null, + Int("serverId") is > 0 and var server ? server : null, new SyntheticCheckSettings { Host = form["host"].ToString(), - Port = Int(form, "port"), + Port = Int("port"), Url = ServerSetup.ParseUrl(form["url"].ToString()), ExpectedStatus = ServerSetup.ParseStatusCodes(form["expectedStatus"].ToString()), ExpectedText = form["expectedText"].ToString(), @@ -165,11 +199,11 @@ public static class ServerSetupEndpoints Configuration = form["configuration"].ToString(), BusinessConnectorLogon = form["businessConnectorLogon"] == "true", ServiceName = form["serviceName"].ToString(), - WarnMilliseconds = Int(form, "warnMilliseconds"), + WarnMilliseconds = Int("warnMilliseconds"), }, form["password"].ToString(), - minutes is { } m ? m * 60 : null, - Int(form, "timeoutSeconds")); + interval, + Int("timeoutSeconds")), null); } private static async Task AddCheckAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) @@ -180,7 +214,10 @@ public static class ServerSetupEndpoints } var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); - var outcome = await ServerSetup.AddCheckAsync(repository, CheckInput(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + var (input, invalid) = CheckInput(form); + var outcome = input is null + ? new ServerSetup.Outcome(Error: invalid) + : await ServerSetup.AddCheckAsync(repository, input, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); return outcome.Error is { } error ? Results.Redirect(WithMessage(ServerSetup.ChecksPage, "fejl", error) + "#tilfoej") : Results.Redirect(ServerSetup.CheckPage(outcome.Id!.Value) + "?tilfoejet=1"); @@ -194,7 +231,10 @@ public static class ServerSetupEndpoints } var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); - var outcome = await ServerSetup.UpdateCheckAsync(repository, id, CheckInput(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + var (input, invalid) = CheckInput(form); + var outcome = input is null + ? new ServerSetup.Outcome(id, Error: invalid) + : await ServerSetup.UpdateCheckAsync(repository, id, input, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); return outcome.NotFound ? Results.NotFound() : Results.Redirect(outcome.Error is { } error ? WithMessage(ServerSetup.CheckPage(id), "fejl", error) + "#ret" : ServerSetup.CheckPage(id) + "?gemt=1"); } @@ -233,6 +273,11 @@ public static class ServerSetupEndpoints } var outcome = await ServerSetup.RequestCheckRunAsync(repository, id, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); - return outcome.NotFound ? Results.NotFound() : Results.Redirect(ServerSetup.CheckPage(id) + "?koersel=" + outcome.RequestId!.Value.ToString(CultureInfo.InvariantCulture)); + return outcome switch + { + { NotFound: true } => Results.NotFound(), + { Error: { } error } => Results.Redirect(WithMessage(ServerSetup.CheckPage(id), "fejl", error)), + _ => Results.Redirect(ServerSetup.CheckPage(id) + "?koersel=" + outcome.RequestId!.Value.ToString(CultureInfo.InvariantCulture)), + }; } } diff --git a/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs b/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs index c717909..a38dd7c 100644 --- a/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs +++ b/tests/SQLInsight.Core.Tests/SyntheticCheckTests.cs @@ -49,6 +49,14 @@ public class SyntheticCheckTests Assert.NotNull(SyntheticCheckValidation.Validate(" ", SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, 300, 15, false)); } + [Fact] + public void An_http_address_has_a_length_limit() + { + Assert.Contains("2000 tegn", Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/" + new string('a', SyntheticCheckValidation.MaxUrlLength)) }), + StringComparison.Ordinal); + Assert.Null(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/" + new string('a', 1900)) })); + } + [Fact] public void Settings_round_trip_as_json_and_bad_json_is_empty_settings() { diff --git a/tests/SQLInsight.IntegrationTests/ServerWebTests.cs b/tests/SQLInsight.IntegrationTests/ServerWebTests.cs index 76eb98c..4d3669b 100644 --- a/tests/SQLInsight.IntegrationTests/ServerWebTests.cs +++ b/tests/SQLInsight.IntegrationTests/ServerWebTests.cs @@ -140,6 +140,10 @@ public sealed partial class ServerWebTests(SqlServerFixture sql) : IAsyncLifetim Assert.Equal(HttpStatusCode.Redirect, saved.StatusCode); Assert.Equal((ServerRoles.File, "LanmanServer", "MyBatch*"), ((await _repository.GetWindowsServerAsync(id))!.Role, (await _repository.GetWindowsServerAsync(id))!.Watched[0], (await _repository.GetWindowsServerAsync(id))!.Ignored[0])); + // The test asked for when the server was added still waits for the agent: it is reused, not queued again. + await PostAsync(client, $"/servere/{id}", $"/servere/{id}/test", []); + Assert.Equal(1, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id))!.RequestId); + await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow); await PostAsync(client, $"/servere/{id}", $"/servere/{id}/test", []); Assert.Equal(2, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id))!.RequestId); await PostAsync(client, $"/servere/{id}", $"/servere/{id}/fjern", []); @@ -220,11 +224,82 @@ public sealed partial class ServerWebTests(SqlServerFixture sql) : IAsyncLifetim ["name"] = "SQL01 AX-login", ["kind"] = "Sql", ["server"] = "evil.example", ["authentication"] = "SqlLogin", ["userName"] = "axapp", }); Assert.Contains("Indtast adgangskoden igen", Uri.UnescapeDataString(moved.Headers.Location!.OriginalString), StringComparison.Ordinal); + // "Kør nu" while the first run still waits for the agent shows that run; once taken, a new one is queued. var firstRun = (await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.RequestId; + var again = await PostAsync(client, $"/syntetiske-tjek/{axId}", $"/syntetiske-tjek/{axId}/koer", []); + Assert.EndsWith("koersel=" + firstRun.ToString(System.Globalization.CultureInfo.InvariantCulture), again.Headers.Location!.OriginalString, StringComparison.Ordinal); + while (await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow) is not null) + { + } + var run = await PostAsync(client, $"/syntetiske-tjek/{axId}", $"/syntetiske-tjek/{axId}/koer", []); Assert.Contains("koersel=", run.Headers.Location!.OriginalString, StringComparison.Ordinal); Assert.True((await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.RequestId > firstRun); await PostAsync(client, $"/syntetiske-tjek/{axId}", $"/syntetiske-tjek/{axId}/fjern", []); Assert.Null(await _repository.GetSyntheticCheckAsync(axId)); } + + [Fact] + public async Task Bad_numbers_are_refused_a_paused_check_is_not_run_and_seconds_are_kept() + { + var client = Client(); + var port = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new() { ["name"] = "Port", ["kind"] = "Tcp", ["host"] = "aos01", ["port"] = "27x12" }); + var interval = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", + new() { ["name"] = "Port", ["kind"] = "Tcp", ["host"] = "aos01", ["port"] = "2712", ["intervalMinutes"] = "fem" }); + var huge = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", + new() { ["name"] = "Port", ["kind"] = "Tcp", ["host"] = "aos01", ["port"] = "2712", ["intervalMinutes"] = "2000000000" }); + + Assert.Contains("Porten skal være et tal.", Uri.UnescapeDataString(port.Headers.Location!.OriginalString), StringComparison.Ordinal); + Assert.Contains("helt antal minutter", Uri.UnescapeDataString(interval.Headers.Location!.OriginalString), StringComparison.Ordinal); + Assert.Contains("mellem 1 minut og 24 timer", Uri.UnescapeDataString(huge.Headers.Location!.OriginalString), StringComparison.Ordinal); + Assert.Empty(await _repository.GetSyntheticChecksAsync()); + + // Set to 90 seconds through the API: the form keeps the seconds, the page says so. + var id = await _repository.AddSyntheticCheckAsync(new SyntheticCheckInput("AOS01 port", SyntheticCheckKind.Tcp, null, + new SyntheticCheckSettings { Host = "aos01", Port = 2712 }, null, null, 90, 15), null, DateTimeOffset.UtcNow); + var page = await client.GetStringAsync($"/syntetiske-tjek/{id}"); + Assert.Contains("hvert 90. sekund", page, StringComparison.Ordinal); + Assert.Contains("name=\"intervalSeconds\"", page, StringComparison.Ordinal); + await PostAsync(client, $"/syntetiske-tjek/{id}", $"/syntetiske-tjek/{id}/ret", + new() { ["name"] = "AOS01 port", ["kind"] = "Tcp", ["host"] = "aos01", ["port"] = "2712", ["intervalSeconds"] = "90" }); + Assert.Equal(90, (await _repository.GetSyntheticCheckAsync(id))!.IntervalSeconds); + + await _repository.SetSyntheticCheckPausedAsync(id, true, null, DateTimeOffset.UtcNow); + var paused = await PostAsync(client, $"/syntetiske-tjek/{id}", $"/syntetiske-tjek/{id}/koer", []); + Assert.Contains("pause", Uri.UnescapeDataString(paused.Headers.Location!.OriginalString), StringComparison.Ordinal); + Assert.Null(await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, id)); + Assert.DoesNotContain("Kør nu", await client.GetStringAsync($"/syntetiske-tjek/{id}"), StringComparison.Ordinal); + } + + [Fact] + public async Task Paused_servers_findings_are_not_on_the_overview_and_a_removed_servers_checks_stay() + { + var client = Client(); + var now = DateTimeOffset.UtcNow; + var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil01", ServerRoles.File, [], []), null, now); + var check = await _repository.AddSyntheticCheckAsync(new SyntheticCheckInput("FIL01 SMB", SyntheticCheckKind.Tcp, server, + new SyntheticCheckSettings { Host = "fil01", Port = 445, ServiceName = "LanmanServer" }, null, null, 300, 15), null, now); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server, + [ + new RuleResult + { + RuleId = "SQLI-WIN-005", RuleTitle = "Disk", Outcome = RuleOutcome.Findings, + Findings = [new Finding { RuleId = "SQLI-WIN-005", Severity = Severity.High, Title = "Disk E: is nearly full", Explanation = "e", Recommendation = "r", Subject = "disk: E:" }], + }, + ], now); + Assert.Contains("Disk E: is nearly full", await client.GetStringAsync("/servere"), StringComparison.Ordinal); + + await PostAsync(client, $"/servere/{server}", $"/servere/{server}/pause", new() { ["paused"] = "true" }); + var list = await client.GetStringAsync("/servere"); + Assert.DoesNotContain("Disk E: is nearly full", list, StringComparison.Ordinal); + Assert.Contains("Servere og tjek sat på pause er ikke med: FIL01", list, StringComparison.Ordinal); + Assert.Contains("Disk E: is nearly full", await client.GetStringAsync($"/servere/{server}"), StringComparison.Ordinal); + + await PostAsync(client, $"/servere/{server}", $"/servere/{server}/fjern", []); + var row = (await _repository.GetSyntheticCheckAsync(check))!; + Assert.Equal((null, null), (row.ServerId, row.Settings.ServiceName)); + var saved = await PostAsync(client, $"/syntetiske-tjek/{check}", $"/syntetiske-tjek/{check}/ret", + new() { ["name"] = "FIL01 SMB", ["kind"] = "Tcp", ["host"] = "fil01", ["port"] = "445", ["intervalMinutes"] = "5" }); + Assert.Contains("gemt=1", saved.Headers.Location!.OriginalString, StringComparison.Ordinal); + } }