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);
}
}