diff --git a/src/SQLInsight.Agent/Servers/ServerMonitor.cs b/src/SQLInsight.Agent/Servers/ServerMonitor.cs index fd7f99a..348196e 100644 --- a/src/SQLInsight.Agent/Servers/ServerMonitor.cs +++ b/src/SQLInsight.Agent/Servers/ServerMonitor.cs @@ -214,7 +214,7 @@ public sealed partial class ServerMonitor( } _lastBoot = os.LastBootUpTime ?? _lastBoot; - var sample = ServerPerformanceMath.Sample(_previousRaw, raw, os); + var sample = ServerPerformanceMath.Sample(_previousRaw, raw, os, options.IntervalOf(ServerArea.Performance)); _previousRaw = raw; _samples = ServerPerformanceMath.Append(_samples, sample); var snapshot = new ServerPerformanceSnapshot @@ -295,7 +295,7 @@ public sealed partial class ServerMonitor( ArgumentNullException.ThrowIfNull(session); var tasks = options.Tasks; var read = await session.GetTasksAsync( - name => (tasks.IncludeMicrosoftTasks || !TaskResults.IsMicrosoftTask(name)) && !WindowsServerRulesAccess.Matches(name, tasks.Ignore), + tasks.Watches, Math.Max(1, tasks.MaxWatched), cancellationToken).ConfigureAwait(false); var snapshot = new ServerTaskSnapshot { CapturedAt = now, Tasks = read.Watched, Visible = read.Visible, Coverage = read.Note }; _data.Set(snapshot); @@ -350,10 +350,3 @@ public sealed partial class ServerMonitor( [LoggerMessage(Level = LogLevel.Debug, Message = "Windows server {Server}: {Findings} findings, {NotMeasurable} rules not measurable")] private static partial void LogCycle(ILogger logger, string server, int findings, int notMeasurable); } - -/// The rules' name matching (names, or prefixes ending in *), for the agent's task filter. -internal static class WindowsServerRulesAccess -{ - public static bool Matches(string name, IEnumerable patterns) => patterns.Any(p => - p.EndsWith('*') ? name.StartsWith(p[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(p, StringComparison.OrdinalIgnoreCase)); -} diff --git a/src/SQLInsight.Rules/Servers/ServerRuleSettings.cs b/src/SQLInsight.Rules/Servers/ServerRuleSettings.cs index abf97cc..2f604da 100644 --- a/src/SQLInsight.Rules/Servers/ServerRuleSettings.cs +++ b/src/SQLInsight.Rules/Servers/ServerRuleSettings.cs @@ -72,6 +72,12 @@ public sealed class ServiceThresholds /// Right after a restart services are still starting: no finding for this many minutes. public int StartupGraceMinutes { get; set; } = 15; + + /// + /// At most this many findings for stopped automatic services per server (SQLI-WIN-007), by name; the rest are named in + /// one summary finding. Between 1 and 100. + /// + public int MaxFindings { get; set; } = 15; } public sealed class EventLogThresholds @@ -82,7 +88,7 @@ public sealed class EventLogThresholds /// Events left out as noise, in addition to the built-in list: "Kilde:id", "Kilde" or "Kilde*". public IList Ignore { get; } = []; - /// At most this many event findings per server, most important first. + /// At most this many event findings per server, most important first; the rest are named in one summary finding. Between 1 and 100. public int MaxFindings { get; set; } = 15; /// At most this many events per log are read in one collection; the rest is read next time. @@ -100,8 +106,23 @@ public sealed class TaskThresholds /// At most this many tasks per server are watched (their run information is read one at a time). public int MaxWatched { get; set; } = 50; + /// + /// Leave out the tasks programs create per user (a user's SID in the name) or to update themselves (OneDrive, Edge, + /// Google, User_Feed_Synchronization; ). On by default. + /// + public bool IgnoreUserAndUpdaterTasks { get; set; } = true; + /// Tasks never watched (path\name, or a prefix ending in *). public IList Ignore { get; } = []; + + /// Whether the task (its full path) is watched: the agent's filter when it reads tasks, and the rules' too. + public bool Watches(string fullName) + { + ArgumentNullException.ThrowIfNull(fullName); + return (IncludeMicrosoftTasks || !TaskResults.IsMicrosoftTask(fullName)) + && !(IgnoreUserAndUpdaterTasks && TaskResults.IsUserOrUpdaterTask(fullName)) + && !WindowsServerRules.Matches(fullName, Ignore); + } } /// What the Windows server rules see: the server, the latest data per area, and the thresholds. diff --git a/src/SQLInsight.Rules/Servers/SyntheticCheckRules.cs b/src/SQLInsight.Rules/Servers/SyntheticCheckRules.cs index b8d7c7f..8edf508 100644 --- a/src/SQLInsight.Rules/Servers/SyntheticCheckRules.cs +++ b/src/SQLInsight.Rules/Servers/SyntheticCheckRules.cs @@ -62,29 +62,43 @@ public sealed class SyntheticCheckFailingRule : ScopedRule { new("Fejl", latest.Message, Sensitive: true), new("Mål", check.Target), - new("Fejlet i træk", failures.Count.ToString(CultureInfo.InvariantCulture)), - new("Fejler siden", SyntheticCheckRules.Time(failures[^1].At)), + new("Fejlet i træk", times), + new("Fejler siden", all + ? $"{SyntheticCheckRules.Time(failures[^1].At)} eller tidligere (ældre resultater er ikke med i vurderingen)" + : SyntheticCheckRules.Time(failures[^1].At)), new("Seneste forsøg", SyntheticCheckRules.Time(latest.At)), }; if (check.ServiceName is not null) { - evidence.Add(new Evidence("Tjeneste", $"{check.ServiceName}: {(latest.ServiceState is { } state ? StateText(state) : "kunne ikke aflæses")}")); + // Not "Tjeneste": the agent adds its own detail by that name. + evidence.Add(new Evidence("Tjenestens tilstand", $"{check.ServiceName}: {(latest.ServiceState is { } state ? StateText(state) : "kunne ikke aflæses")}")); } evidence.AddRange(latest.Details.Select(d => new Evidence(d.Name, d.Value, d.Sensitive))); @@ -97,14 +111,20 @@ public sealed class SyntheticCheckFailingRule : ScopedRuleThe service state the agent records when the check's service does not exist on the server. + private const string NotFound = "NotFound"; + private static string StateText(string state) => state switch { "Running" => "kører", "Stopped" => "stoppet", "Start Pending" => "ved at starte", "Stop Pending" => "ved at stoppe", + "Continue Pending" => "ved at fortsætte", + "Pause Pending" => "ved at gå på pause", "Paused" => "sat på pause", - _ => state, + NotFound => "findes ikke", + _ => $"i tilstanden \"{state}\"", }; private static string What(string kind) => kind switch @@ -194,6 +214,8 @@ public sealed class CertificateExpiryRule : ScopedRule new(title, new Uri("https://learn.microsoft.com/" + path)); /// A name in a list where entries may end in * (prefix). - internal static bool Matches(string name, IEnumerable patterns) => patterns.Any(p => - p.EndsWith('*') ? name.StartsWith(p[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(p, StringComparison.OrdinalIgnoreCase)); + internal static bool Matches(string name, IEnumerable patterns) => patterns.Any(p => Matches(name, p)); + + private static bool Matches(string name, string pattern) => + pattern.EndsWith('*') ? name.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(pattern, StringComparison.OrdinalIgnoreCase); + + /// + /// Whether a service is meant by a name in a service list (watched, ignored): its name or its display name, either + /// one exactly or by a prefix ending in *. SQLI-WIN-007 and SQLI-WIN-008 match the same way, so a service is never + /// reported by both or "not found" by one and found by the other. + /// + internal static bool IsService(ServerService service, string pattern) => Matches(service.Name, pattern) || Matches(service.DisplayName, pattern); + + internal static bool IsService(ServerService service, IEnumerable patterns) => patterns.Any(p => IsService(service, p)); + + /// A "max findings" setting, kept between 1 and 100. + internal static int Bounded(int maxFindings) => Math.Clamp(maxFindings, 1, 100); + + /// Names for the summary of the findings over the cap: at most 50, then "og N flere". + internal static string List(IReadOnlyCollection names) => + string.Join(", ", names.Take(50)) + (names.Count > 50 ? $" og {names.Count - 50} flere" : string.Empty); } public sealed class ServerCpuRule : ScopedRule @@ -239,9 +257,11 @@ public sealed class DiskForecastRule : ScopedRule ArgumentNullException.ThrowIfNull(context); var disks = context.Data.Get(); var settings = context.Settings.Disks; - if (disks.Disks.Count > 0 && disks.Forecasts.Count == 0) + if (disks.Disks.Count > 0 && !disks.Forecasts.Any(f => f.EnoughHistory)) { - return RuleEvaluation.NotMeasurable("Prognosen beregnes ud fra SQLInsights egne målinger af ledig plads; der er endnu ingen historik."); + var note = disks.Forecasts.Select(f => f.Note).FirstOrDefault(n => n is not null); + return RuleEvaluation.NotMeasurable("Prognosen beregnes ud fra SQLInsights egne målinger af ledig plads over mindst et par dage; " + + (note is null ? "der er endnu ingen historik." : $"der er ikke nok endnu ({note}).")); } return Result(disks.Forecasts @@ -252,7 +272,9 @@ public sealed class DiskForecastRule : ScopedRule var disk = disks.Disks.FirstOrDefault(d => string.Equals(d.Volume, f.Volume, StringComparison.OrdinalIgnoreCase)); return CreateFinding( days <= 2 ? Severity.Critical : days <= 7 ? Severity.High : Severity.Medium, - $"Disken {f.Volume} er {f.Describe()}", + days <= 0 ? $"Drev {f.Volume} er fuldt" + : days < 1 ? $"Drev {f.Volume} er fuldt inden for et døgn" + : $"Drev {f.Volume} er fuldt om ca. {WindowsServerRules.Number(Math.Round(days))} dage", $"Pladsen på drevet er de seneste {WindowsServerRules.Number(f.HistoryDays)} dage brugt med ca. {WindowsServerRules.Number(f.GrowthGbPerDay, 1)} GB " + "om dagen. Fortsætter det, er drevet fuldt, og så stopper alt, der skriver til det (databaser, backup, logfiler). Prognosen er en ret linje " + "gennem SQLInsights egne målinger og tager ikke højde for oprydning, der ikke er sket endnu.", @@ -260,7 +282,7 @@ public sealed class DiskForecastRule : ScopedRule + "backup-rotation faktisk kører.", [ new Evidence("Fuldt om ca.", WindowsServerRules.Number(days, days < 10 ? 1 : 0) + " dage"), - new Evidence("Forventet fuld", f.FullAt(context.Now) is { } at ? at.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : "–"), + new Evidence("Forventet fuld", f.FullAt(disks.CapturedAt) is { } at ? at.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : "–"), new Evidence("Vækst pr. dag", WindowsServerRules.Number(f.GrowthGbPerDay, 2) + " GB"), new Evidence("Ledig nu", disk is null ? "–" : WindowsServerRules.Number(disk.FreeGb, 1) + " GB af " + WindowsServerRules.Number(disk.SizeGb, 1) + " GB"), new Evidence("Historik", $"{WindowsServerRules.Number(f.HistoryDays, 1)} dage, {f.Samples} målinger, R² {WindowsServerRules.Number(f.Fit, 2)}"), @@ -288,15 +310,39 @@ public sealed class StoppedAutomaticServiceRule : ScopedRule { ArgumentNullException.ThrowIfNull(context); var services = context.Data.Get(); - if (InGracePeriod(context)) + if (GraceReason(context) is { } grace) { - return RuleEvaluation.Passed(); + return RuleEvaluation.NotMeasurable(grace); } var ignore = StopByDesign.Concat(context.Settings.Services.Ignore).Concat(context.Server.IgnoredServices).ToList(); - return Result(services.Services - .Where(s => s.IsAutomatic && !s.IsRunning && !WindowsServerRules.Matches(s.Name, ignore) && !WindowsServerRules.Matches(s.Name, context.Server.WatchedServices)) - .Select(s => CreateFinding( + var stopped = services.Services + .Where(s => s.IsAutomatic && !s.IsRunning && !WindowsServerRules.IsService(s, ignore) && !WindowsServerRules.IsService(s, context.Server.WatchedServices)) + .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var max = WindowsServerRules.Bounded(context.Settings.Services.MaxFindings); + var findings = stopped.Take(max).Select(One).ToList(); + if (stopped.Count > max) + { + var rest = stopped.Skip(max).Select(s => s.Name).ToList(); + findings.Add(CreateFinding( + Severity.Medium, + $"{rest.Count} automatiske tjenester mere kører ikke", + $"Ud over de {max} tjenester med egne fund står yderligere {rest.Count} tjenester, der er sat til at starte automatisk, som ikke kørende. " + + "Så mange stoppede tjenester på én gang skyldes ofte en fælles årsag: en tjeneste, de afhænger af, en konto med udløbet adgangskode, " + + "eller en genstart, der ikke blev fuldført.", + "Se Windows' hændelseslog (System, Service Control Manager) og listen her. Tjenester, der skal være stoppet, sættes til Manuel eller " + + "Deaktiveret eller tilføjes til listen over tjenester, der ignoreres for serveren. SQLInsight starter aldrig selv tjenester.", + [ + new Evidence("Tjenester", WindowsServerRules.List(rest)), + new Evidence("Fund pr. tjeneste", $"højst {max} (SQLInsight:Servers:Services:MaxFindings)"), + ], + subject: "service: (flere)")); + } + + return Result(findings); + + Finding One(ServerService s) => CreateFinding( Severity.Medium, $"Tjenesten {s.DisplayName} ({s.Name}) kører ikke", $"Tjenesten er sat til at starte automatisk, men står som \"{s.State}\"" + (s.ExitCode is { } code and not 0 and not 1077 ? $" (afslutningskode {code})" : string.Empty) @@ -310,12 +356,25 @@ public sealed class StoppedAutomaticServiceRule : ScopedRule new Evidence("Start", s.StartMode + (s.DelayedAutoStart ? " (forsinket)" : string.Empty)), new Evidence("Afslutningskode", s.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "–"), ], - subject: "service: " + s.Name))); + subject: "service: " + s.Name); } - internal static bool InGracePeriod(ServerRuleContext context) => - context.Data.TryGet(out var performance) && performance.Os.LastBootUpTime is { } boot - && context.Now - boot < TimeSpan.FromMinutes(context.Settings.Services.StartupGraceMinutes); + /// + /// Right after a restart services are still starting. Not "passed": that would resolve open findings at every restart + /// and open them again a quarter of an hour later. Not measurable keeps them as they are. + /// + internal static string? GraceReason(ServerRuleContext context) + { + if (!context.Data.TryGet(out var performance) || performance.Os.LastBootUpTime is not { } boot) + { + return null; + } + + var left = TimeSpan.FromMinutes(context.Settings.Services.StartupGraceMinutes) - (context.Now - boot); + return left > TimeSpan.Zero + ? $"Serveren er lige startet; tjenester vurderes igen efter {Math.Max(1, (int)Math.Ceiling(left.TotalMinutes)).ToString(CultureInfo.InvariantCulture)} minutter." + : null; + } } /// Services chosen for the server (the AOS, IIS, SQL Server …) that must run, whatever their start mode. @@ -333,29 +392,30 @@ public sealed class WatchedServiceRule : ScopedRule } var services = context.Data.Get(); - if (StoppedAutomaticServiceRule.InGracePeriod(context)) + if (StoppedAutomaticServiceRule.GraceReason(context) is { } grace) { - return RuleEvaluation.Passed(); + return RuleEvaluation.NotMeasurable(grace); } var findings = new List(); + var reported = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var name in context.Server.WatchedServices) { - var service = services.Services.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || s.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase)); - if (service is null) + var matching = services.Services.Where(s => WindowsServerRules.IsService(s, name)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList(); + if (matching.Count == 0) { findings.Add(CreateFinding( Severity.Medium, $"Den overvågede tjeneste {name} findes ikke eller kan ikke ses", "Tjenesten står på listen over tjenester, der skal køre på serveren, men Windows viser den ikke. Den kan være afinstalleret eller omdøbt, " + "eller agenten må ikke se den (tjenestens egne sikkerhedsindstillinger).", - "Tjek tjenestens navn (ikke visningsnavnet) i services.msc, og ret listen. Kør tildelingsscriptet igen, hvis agenten mangler rettigheder.", + "Tjek tjenestens navn eller visningsnavn i services.msc (en * til sidst matcher alle, der begynder sådan), og ret listen. Kør tildelingsscriptet igen, hvis agenten mangler rettigheder.", [new Evidence("Tjeneste", name), new Evidence("Tjenester agenten kan se", services.Services.Count.ToString(CultureInfo.InvariantCulture))], subject: "watched: " + name)); continue; } - if (!service.IsRunning) + foreach (var service in matching.Where(s => !s.IsRunning && reported.Add(s.Name))) { findings.Add(CreateFinding( Severity.High, @@ -387,12 +447,34 @@ public sealed class WindowsEventLogRule : ScopedRule ArgumentNullException.ThrowIfNull(context); var events = context.Data.Get(); var settings = context.Settings.EventLog; - return Result(events.Recent + // A stable order (kind, then the oldest first, then the key), so the same groups keep their findings from one + // evaluation to the next instead of trading places as counts change. + var relevant = events.Recent .Where(g => g.Category != ServerEventCategory.UnexpectedShutdown) .Where(g => g.Category != ServerEventCategory.Error || g.Count >= Math.Max(1, settings.RepeatThreshold)) - .OrderByDescending(g => g.Category).ThenByDescending(g => g.Count) - .Take(Math.Max(1, settings.MaxFindings)) - .Select(g => CreateFinding( + .OrderByDescending(g => g.Category).ThenBy(g => g.FirstAt).ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + var max = WindowsServerRules.Bounded(settings.MaxFindings); + var findings = relevant.Take(max).Select(One).ToList(); + if (relevant.Count > max) + { + var rest = relevant.Skip(max).ToList(); + findings.Add(CreateFinding( + rest.Max(SeverityOf), + $"{rest.Count} hændelser mere i Windows' hændelseslog", + $"Ud over de {max} hændelser med egne fund er der yderligere {rest.Count} slags fejl i hændelsesloggen det seneste døgn. " + + "Så mange forskellige fejl på én gang har ofte en fælles årsag (en disk, netværket, en tjeneste mange andre afhænger af).", + "Se hændelserne i Logbog (Event Viewer) på serveren, de vigtigste først. Harmløse kilder kan ignoreres under SQLInsight:Servers:EventLog:Ignore.", + [ + new Evidence("Hændelser", WindowsServerRules.List(rest.Select(g => $"{g.Source} {g.EventId} ({g.Count})").ToList())), + new Evidence("Fund pr. hændelse", $"højst {max} (SQLInsight:Servers:EventLog:MaxFindings)"), + ], + subject: "event: (flere)")); + } + + return Result(findings); + + Finding One(ServerEventGroup g) => CreateFinding( SeverityOf(g), $"{ServerEventAnalysis.Display(g.Category)}: {g.Source} {g.EventId} ({g.Count} {(g.Count == 1 ? "gang" : "gange")} i {g.Log}-loggen)", Explanation(g.Category), @@ -407,7 +489,7 @@ public sealed class WindowsEventLogRule : ScopedRule new Evidence("Seneste", WindowsServerRules.Time(g.LastAt)), new Evidence("Eksempel", g.Sample ?? "(ingen tekst)", Sensitive: true), ], - subject: "event: " + g.Key))); + subject: "event: " + g.Key); } private static Severity SeverityOf(ServerEventGroup group) => group.Category switch @@ -490,6 +572,27 @@ public sealed class ServerRestartRule : ScopedRule subject: "restart")); } + if (events is null) + { + // The event log cannot be read: say that the cause is unknown rather than call it planned. + return RuleEvaluation.Found(CreateFinding( + Severity.Low, + $"Serveren er genstartet {WindowsServerRules.Time(boot)} (årsagen kan ikke ses)", + "Serveren er genstartet det seneste døgn. Agenten kan ikke læse Windows' hændelseslog på serveren, så det vides ikke, om genstarten var " + + "planlagt (fx Windows-opdateringer) eller uventet (strøm, blå skærm, hård nulstilling). Fundet forsvinder af sig selv efter et døgn.", + "Se Logbog (System) omkring tidspunktet: User32 1074 er en planlagt genstart; Kernel-Power 41, EventLog 6008 og BugCheck 1001 en uventet. " + + "Giv agenten læseret til hændelsesloggen (Event Log Readers, se tildelingsscriptet), så SQLInsight selv kan skelne.", + evidence, + subject: "restart")); + } + + if (events.ReadUntil < boot || events.CapturedAt <= boot) + { + // Kernel-Power 41 and EventLog 6008 are written while Windows starts; until the log is read past the start they cannot have been seen. + return RuleEvaluation.NotMeasurable("Hændelsesloggen er ikke læst efter genstarten endnu, så det vides ikke, om den var planlagt eller uventet. " + + "Det vurderes, når hændelsesloggen er indsamlet igen."); + } + return RuleEvaluation.Found(CreateFinding( Severity.Info, $"Serveren er genstartet {WindowsServerRules.Time(boot)}", @@ -544,21 +647,21 @@ public sealed class FailedScheduledTaskRule : ScopedRule ArgumentNullException.ThrowIfNull(context); var tasks = context.Data.Get(); return Result(tasks.Tasks - .Where(t => t.Enabled && TaskResults.IsFailure(t.LastTaskResult) && !WindowsServerRules.Matches(t.FullName, context.Settings.Tasks.Ignore)) + .Where(t => t.Enabled && TaskResults.IsFailure(t.LastTaskResult) && context.Settings.Tasks.Watches(t.FullName)) .Select(t => CreateFinding( Severity.Medium, - $"Opgaven {t.FullName} fejlede: {TaskResults.Describe(t.LastTaskResult!.Value)}", + $"Opgaven {TaskResults.Display(t.FullName)} fejlede: {TaskResults.Describe(t.LastTaskResult!.Value)}", "Opgaven i Opgavestyring (Task Scheduler) sluttede med en fejlkode sidste gang, den kørte. Planlagte opgaver er ofte oprydning, eksport, " + "integrationer eller backup af filer: når de fejler, opdager ingen det, før noget mangler.", "Åbn opgaven i Opgavestyring, se fanen Historik og opgavens egen log. Tjek kontoen, opgaven kører som (adgangskode, rettigheder), og at " + "programmet eller scriptet findes.", [ - new Evidence("Opgave", t.FullName), + new Evidence("Opgave", t.FullName, Sensitive: TaskResults.HasUserSid(t.FullName)), new Evidence("Resultat", TaskResults.Describe(t.LastTaskResult.Value)), new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)), new Evidence("Næste kørsel", WindowsServerRules.Time(t.NextRunTime)), ], - subject: "task: " + t.FullName))); + subject: "task: " + TaskResults.Display(t.FullName)))); } } @@ -574,21 +677,21 @@ public sealed class MissedScheduledTaskRule : ScopedRule var late = TimeSpan.FromMinutes(Math.Max(1, context.Settings.Tasks.LateMinutes)); return Result(tasks.Tasks .Where(t => t.Enabled && !string.Equals(t.State, "Running", StringComparison.OrdinalIgnoreCase) - && !WindowsServerRules.Matches(t.FullName, context.Settings.Tasks.Ignore) + && context.Settings.Tasks.Watches(t.FullName) && (t.MissedRuns > 0 || (t.NextRunTime is { } next && next < context.Now - late && (t.LastRunTime is null || t.LastRunTime < next)))) .Select(t => CreateFinding( Severity.Medium, - t.MissedRuns > 0 ? $"Opgaven {t.FullName} har sprunget {t.MissedRuns} kørsler over" : $"Opgaven {t.FullName} kørte ikke {WindowsServerRules.Time(t.NextRunTime)}", + t.MissedRuns > 0 ? $"Opgaven {TaskResults.Display(t.FullName)} har sprunget {t.MissedRuns} kørsler over" : $"Opgaven {TaskResults.Display(t.FullName)} kørte ikke {WindowsServerRules.Time(t.NextRunTime)}", "Opgaven skulle have kørt, men gjorde det ikke. Det sker, når Opgavestyring ikke kører, når opgavens betingelser ikke er opfyldt (fx \"kun når " + "brugeren er logget på\"), eller når serveren var slukket. Det, opgaven gør, er ikke sket.", "Se opgavens Historik i Opgavestyring og dens betingelser og indstillinger (\"Kør opgaven hurtigst muligt efter en mistet start\").", [ - new Evidence("Opgave", t.FullName), + new Evidence("Opgave", t.FullName, Sensitive: TaskResults.HasUserSid(t.FullName)), new Evidence("Planlagt", WindowsServerRules.Time(t.NextRunTime)), new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)), new Evidence("Mistede kørsler", t.MissedRuns.ToString(CultureInfo.InvariantCulture)), new Evidence("Status", t.State), ], - subject: "task-missed: " + t.FullName))); + subject: "task-missed: " + TaskResults.Display(t.FullName)))); } } diff --git a/tests/SQLInsight.Rules.Tests/WindowsServerRuleTests.cs b/tests/SQLInsight.Rules.Tests/WindowsServerRuleTests.cs index ce3605b..8e3c3de 100644 --- a/tests/SQLInsight.Rules.Tests/WindowsServerRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/WindowsServerRuleTests.cs @@ -130,9 +130,37 @@ public class WindowsServerRuleTests var findings = Run(new DiskForecastRule(), Context(With(disks))).Findings; Assert.Equal(2, findings.Count); - Assert.Contains(findings, f => f.Title == @"Disken D:\ er fuld om ca. 12 dage" && f.Severity == Severity.Medium); + Assert.Contains(findings, f => f.Title == @"Drev D:\ er fuldt om ca. 12 dage" && f.Severity == Severity.Medium); Assert.Contains(findings, f => f.Subject == @"forecast: E:\" && f.Severity == Severity.Critical); Assert.Equal(RuleOutcome.NotMeasurable, Run(new DiskForecastRule(), Context(With(disks with { Forecasts = [] }))).Outcome); + + var full = Run(new DiskForecastRule(), Context(With(disks with { Forecasts = [new DiskForecast(@"D:\", 0, 8, 300, 14, 0.95)] }))); + Assert.Equal(@"Drev D:\ er fuldt", Assert.Single(full.Findings).Title); + } + + [Fact] + public void A_new_server_has_no_forecast_yet_and_says_so() + { + // What the agent builds for a server it started watching yesterday: one forecast per volume, none with enough history. + var history = Enumerable.Range(0, 24).Select(h => new DiskHistoryPoint(Now.AddHours(-h), 500, 100 + h)).Reverse().ToList(); + var disks = new ServerDiskSnapshot + { + CapturedAt = Now, + Disks = [new ServerDisk(@"C:\", null, "NTFS", 500, 100), new ServerDisk(@"D:\", null, "NTFS", 500, 100)], + Forecasts = [DiskForecaster.Forecast(@"C:\", history, Now), DiskForecaster.Forecast(@"D:\", [], Now)], + }; + + var result = Run(new DiskForecastRule(), Context(With(disks))); + + Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); + Assert.Contains("for lidt historik", result.Reason, StringComparison.Ordinal); + + // Once one volume has enough history, the rule judges it (and passes, as it is not filling up). + var enough = Enumerable.Range(0, 24 * 5).Select(h => new DiskHistoryPoint(Now.AddHours(-h), 500, 100)).Reverse().ToList(); + Assert.Equal(RuleOutcome.Passed, Run(new DiskForecastRule(), Context(With(disks with + { + Forecasts = [DiskForecaster.Forecast(@"C:\", enough, Now), DiskForecaster.Forecast(@"D:\", [], Now)], + }))).Outcome); } private static ServerServiceSnapshot Services(params ServerService[] services) => new() { CapturedAt = Now, Services = services }; @@ -162,7 +190,52 @@ public class WindowsServerRuleTests var data = With(Services(new ServerService("AOS60$01", "AOS", "Stopped", "Auto"))); data.Set(Performance(_ => Sample(), boot: Now.AddMinutes(-5))); - Assert.Equal(RuleOutcome.Passed, Run(new StoppedAutomaticServiceRule(), Context(data)).Outcome); + // Not "passed": that would resolve the open findings at every restart and open them again later. + var automatic = Run(new StoppedAutomaticServiceRule(), Context(data)); + var watched = Run(new WatchedServiceRule(), Context(data, Server(watched: ["AOS60$01"]))); + + Assert.Equal(RuleOutcome.NotMeasurable, automatic.Outcome); + Assert.Equal("Serveren er lige startet; tjenester vurderes igen efter 10 minutter.", automatic.Reason); + Assert.Equal(RuleOutcome.NotMeasurable, watched.Outcome); + } + + [Fact] + public void Watched_and_automatic_services_are_matched_the_same_way() + { + var data = With(Services( + new ServerService("AOS60$01", "Microsoft Dynamics AX Object Server 6.0$01", "Stopped", "Auto"), + new ServerService("AOS60$02", "Microsoft Dynamics AX Object Server 6.0$02", "Stopped", "Auto"), + new ServerService("ReportServer", "SQL Server Reporting Services", "Stopped", "Auto"))); + data.Set(Performance(_ => Sample())); + var server = Server(watched: ["AOS60$*", "SQL Server Reporting Services"]); + + var watched = Run(new WatchedServiceRule(), Context(data, server)); + var automatic = Run(new StoppedAutomaticServiceRule(), Context(data, server)); + + // A prefix finds both AOS instances and a display name finds Reporting Services: none is "not found", and none is reported twice. + Assert.Equal(["watched: AOS60$01", "watched: AOS60$02", "watched: ReportServer"], watched.Findings.Select(f => f.Subject!).Order(StringComparer.Ordinal).ToList()); + Assert.All(watched.Findings, f => Assert.Equal(Severity.High, f.Severity)); + Assert.Equal(RuleOutcome.Passed, automatic.Outcome); + } + + [Fact] + public void Stopped_automatic_services_are_capped_with_one_summary() + { + var data = With(Services(Enumerable.Range(1, 25).Select(i => new ServerService($"Svc{i:00}", $"Service {i}", "Stopped", "Auto")).Reverse().ToArray())); + data.Set(Performance(_ => Sample())); + + var findings = Run(new StoppedAutomaticServiceRule(), Context(data)).Findings; + + Assert.Equal(16, findings.Count); + Assert.Equal(Enumerable.Range(1, 15).Select(i => $"service: Svc{i:00}"), findings.Take(15).Select(f => f.Subject)); + var summary = findings[^1]; + Assert.Equal("service: (flere)", summary.Subject); + Assert.Equal("10 automatiske tjenester mere kører ikke", summary.Title); + Assert.Contains("Svc16", summary.Evidence.Single(e => e.Name == "Tjenester").Value, StringComparison.Ordinal); + + var settings = new ServerRuleSettings(); + settings.Services.MaxFindings = 0; // bounded: at least one + Assert.Equal(2, Run(new StoppedAutomaticServiceRule(), Context(data, settings: settings)).Findings.Count); } [Fact] @@ -218,13 +291,36 @@ public class WindowsServerRuleTests Recent = Enumerable.Range(1, 40).Select(i => Group("App" + i, i, ServerEventCategory.ApplicationCrash, 1)).ToList(), }; - Assert.Equal(15, Run(new WindowsEventLogRule(), Context(With(events))).Findings.Count); + var findings = Run(new WindowsEventLogRule(), Context(With(events))).Findings; + + Assert.Equal(16, findings.Count); + Assert.Equal("event: (flere)", findings[^1].Subject); + Assert.Equal("25 hændelser mere i Windows' hændelseslog", findings[^1].Title); + Assert.Equal(Severity.Medium, findings[^1].Severity); + } + + [Fact] + public void Event_findings_over_the_cap_do_not_trade_places_as_counts_change() + { + ServerEventSnapshot Snapshot(Func count) => new() + { + CapturedAt = Now, + ReadUntil = Now, + Recent = Enumerable.Range(1, 20).Select(i => Group("App" + i, i, ServerEventCategory.ApplicationCrash, count(i)) with { FirstAt = Now.AddHours(-20).AddMinutes(i) }).ToList(), + }; + + var before = Run(new WindowsEventLogRule(), Context(With(Snapshot(_ => 1)))).Findings.Select(f => f.Subject).ToList(); + var after = Run(new WindowsEventLogRule(), Context(With(Snapshot(i => i)))).Findings.Select(f => f.Subject).ToList(); + + Assert.Equal(before, after); + Assert.Equal("event: System/App1/1", before[0]); } [Fact] public void Restart_unexpected_with_a_crash_event_otherwise_info() { var data = With(Performance(_ => Sample(), boot: Now.AddHours(-3))); + data.Set(new ServerEventSnapshot { CapturedAt = Now, ReadUntil = Now.AddMinutes(-10) }); var planned = Run(new ServerRestartRule(), Context(data)); data.Set(new ServerEventSnapshot { @@ -240,6 +336,24 @@ public class WindowsServerRuleTests Assert.Equal(RuleOutcome.Passed, old.Outcome); } + [Fact] + public void A_restart_is_not_called_planned_before_the_event_log_is_read_past_it() + { + var data = With(Performance(_ => Sample(), boot: Now.AddMinutes(-4))); + + // The event log cannot be read at all: the cause is unknown, and the finding says so. + var unreadable = Run(new ServerRestartRule(), Context(data)); + Assert.Equal(Severity.Low, Assert.Single(unreadable.Findings).Severity); + Assert.Contains("årsagen kan ikke ses", unreadable.Findings[0].Title, StringComparison.Ordinal); + Assert.DoesNotContain("uden tegn", unreadable.Findings[0].Explanation, StringComparison.Ordinal); + + // Read last before the restart: not measurable until it is read again. + data.Set(new ServerEventSnapshot { CapturedAt = Now.AddMinutes(-6), ReadUntil = Now.AddMinutes(-16) }); + var notYet = Run(new ServerRestartRule(), Context(data)); + Assert.Equal(RuleOutcome.NotMeasurable, notYet.Outcome); + Assert.Contains("ikke læst efter genstarten", notYet.Reason, StringComparison.Ordinal); + } + [Fact] public void Uptime_rule_is_off_by_default() { @@ -274,6 +388,49 @@ public class WindowsServerRuleTests Assert.Equal(@"task: \AX export", Assert.Single(failed.Findings).Subject); Assert.Equal([@"task-missed: \Firma\Late", @"task-missed: \Missed"], missed.Findings.Select(f => f.Subject).ToList()); } + + [Theory] + [InlineData(@"\OneDrive Standalone Update Task-S-1-5-21-1004336348-1177238915-682003330-1001", true)] + [InlineData(@"\MicrosoftEdgeUpdateTaskMachineUA", true)] + [InlineData(@"\GoogleUpdateTaskMachineCore{6B8F5D2E-1D0B-4C1B-9E1A-2F8E7A0C1D11}", true)] + [InlineData(@"\User_Feed_Synchronization-{9A1B7C3D-0000-4E5F-8A9B-123456789ABC}", true)] + [InlineData(@"\Firma\Sync for S-1-5-21-1-2-3-500", true)] + [InlineData(@"\AX export", false)] + [InlineData(@"\Firma\OneDrive-kopi af rapporter", false)] + public void Per_user_and_updater_tasks_are_not_watched_by_default(string path, bool left) + { + Assert.Equal(left, !new TaskThresholds().Watches(path)); + Assert.True(new TaskThresholds { IgnoreUserAndUpdaterTasks = false }.Watches(path)); + } + + [Fact] + public void A_users_sid_is_never_in_a_task_findings_title_or_subject() + { + const string sid = "S-1-5-21-1004336348-1177238915-682003330-1001"; + var tasks = new ServerTaskSnapshot + { + CapturedAt = Now, + Tasks = [new ServerTask { Path = @"\", Name = "Rapport-" + sid, State = "Ready", LastTaskResult = 1, LastRunTime = Now.AddDays(-2), NextRunTime = Now.AddHours(-5) }], + }; + var settings = new ServerRuleSettings(); + + // By default the task is left out … + Assert.Equal(RuleOutcome.Passed, Run(new FailedScheduledTaskRule(), Context(With(tasks), settings: settings)).Outcome); + + // … and when it is watched anyway, the SID is only in sensitive evidence. + settings.Tasks.IgnoreUserAndUpdaterTasks = false; + var findings = Run(new FailedScheduledTaskRule(), Context(With(tasks), settings: settings)).Findings + .Concat(Run(new MissedScheduledTaskRule(), Context(With(tasks), settings: settings)).Findings).ToList(); + + Assert.Equal(2, findings.Count); + Assert.All(findings, f => + { + Assert.DoesNotContain(sid, f.Title, StringComparison.Ordinal); + Assert.DoesNotContain(sid, f.Subject, StringComparison.Ordinal); + Assert.Contains(@"\Rapport-bruger-", f.Title, StringComparison.Ordinal); + Assert.True(f.Evidence.Single(e => e.Name == "Opgave").Sensitive); + }); + } } public class SyntheticCheckRuleTests @@ -295,7 +452,7 @@ public class SyntheticCheckRuleTests Assert.Equal(RuleOutcome.Passed, Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(true, 5)).Outcome); var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(true, 10)); Assert.Equal(Severity.High, Assert.Single(failing.Findings).Severity); - Assert.Equal("AOS01 port fejler: Forbindelsen blev afvist.", failing.Findings[0].Title); + Assert.Equal("AOS01 port fejler (2 gange i træk)", failing.Findings[0].Title); Assert.Equal(RuleOutcome.NotMeasurable, Run(new SyntheticCheckFailingRule(), Check()).Outcome); } @@ -308,6 +465,41 @@ public class SyntheticCheckRuleTests Assert.Equal("AOS01 port svarer ikke, selv om tjenesten AOS60$01 kører", running.Findings[0].Title); Assert.Contains("en tjenesteovervågning alene ikke ser", running.Findings[0].Explanation, StringComparison.Ordinal); Assert.Equal("AOS01 port svarer ikke: tjenesten AOS60$01 er stoppet", stopped.Findings[0].Title); + Assert.Equal("AOS60$01: stoppet", stopped.Findings[0].Evidence.Single(e => e.Name == "Tjenestens tilstand").Value); + } + + [Fact] + public void A_service_that_does_not_exist_is_said_in_danish() + { + var missing = Run(new SyntheticCheckFailingRule(), Check(service: "AOS60$01"), Result(false, 0, service: "NotFound"), Result(false, 5, service: "NotFound")); + + var finding = Assert.Single(missing.Findings); + Assert.Equal("AOS01 port svarer ikke: tjenesten AOS60$01 findes ikke", finding.Title); + Assert.Contains("findes ikke på AOS01", finding.Explanation, StringComparison.Ordinal); + Assert.DoesNotContain("NotFound", finding.Title + finding.Explanation, StringComparison.Ordinal); + } + + [Fact] + public void The_failure_message_is_never_in_the_title() + { + var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(false, 10), Result(true, 15)); + + var finding = Assert.Single(failing.Findings); + Assert.DoesNotContain("afvist", finding.Title, StringComparison.Ordinal); + Assert.True(finding.Evidence.Single(e => e.Name == "Fejl").Sensitive); + Assert.Equal("3", finding.Evidence.Single(e => e.Name == "Fejlet i træk").Value); + Assert.Equal("2026-10-02 11:50 UTC", finding.Evidence.Single(e => e.Name == "Fejler siden").Value); + } + + [Fact] + public void When_every_result_failed_the_run_may_be_longer() + { + var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(false, 10)); + + var finding = Assert.Single(failing.Findings); + Assert.Equal("AOS01 port fejler (mindst 3 gange i træk)", finding.Title); + Assert.Equal("mindst 3", finding.Evidence.Single(e => e.Name == "Fejlet i træk").Value); + Assert.StartsWith("2026-10-02 11:50 UTC eller tidligere", finding.Evidence.Single(e => e.Name == "Fejler siden").Value, StringComparison.Ordinal); } [Fact] @@ -330,5 +522,7 @@ public class SyntheticCheckRuleTests Assert.Equal(Severity.Critical, Run(new CertificateExpiryRule(), check, Result(false, 0, certificate: Now.AddDays(-1))).Findings[0].Severity); Assert.Equal(RuleOutcome.NotApplicable, Run(new CertificateExpiryRule(), Check(), Result(true, 0)).Outcome); Assert.Equal(RuleOutcome.NotMeasurable, Run(new CertificateExpiryRule(), check, Result(false, 0)).Outcome); + Assert.Equal("Certifikatet for AOS01 port udløber inden for et døgn", Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddHours(5))).Findings[0].Title); + Assert.Equal("Certifikatet for AOS01 port udløber om 1 dag", Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddHours(30))).Findings[0].Title); } }