Review 1.5: server and check rules never pass unmeasured, cap and stabilize findings, keep sensitive text out of titles
- SQLI-WIN-006 is "ikke målbar" (with the forecaster's note) until a volume has enough
history; titles "Drev X er fuldt (om ca. N dage | inden for et døgn)", as SQLI-WIN-005.
- SQLI-WIN-007/008: the startup grace period is "ikke målbar" instead of passed, so
open findings are not resolved and reopened at every restart. One matcher for both
rules (name or display name, prefix*). SQLI-WIN-007 is capped by
Services:MaxFindings (default 15, 1-100) with one summary finding for the rest.
- SQLI-WIN-009: stable order (category, oldest first, key) and one summary finding
over MaxFindings (now bounded 1-100).
- SQLI-WIN-010 only says "uden tegn på at det skete uventet" when the event log was
read past the boot; before that "ikke målbar", and Low "årsagen kan ikke ses" when
the log cannot be read.
- SQLI-WIN-012/013: Tasks:IgnoreUserAndUpdaterTasks (default true) leaves out per-user
and updater tasks; TaskThresholds.Watches is the one filter for the agent and the
rules. A SID is never in a title or subject, only in sensitive evidence.
- SQLI-SYN-001: neutral title "{navn} fejler ({n} gange i træk)", the failure message
only in sensitive evidence; "mindst N" and "... eller tidligere" when every result
given failed; "findes ikke" for a missing service; evidence "Tjenestens tilstand".
- SQLI-SYN-003: "udløber inden for et døgn" / "om 1 dag".
- Agent (minimal): the task filter is TaskThresholds.Watches, and performance samples
get the configured interval.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2345b199fa
commit
7e66dc94c4
5 changed files with 394 additions and 61 deletions
|
|
@ -214,7 +214,7 @@ public sealed partial class ServerMonitor(
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastBoot = os.LastBootUpTime ?? _lastBoot;
|
_lastBoot = os.LastBootUpTime ?? _lastBoot;
|
||||||
var sample = ServerPerformanceMath.Sample(_previousRaw, raw, os);
|
var sample = ServerPerformanceMath.Sample(_previousRaw, raw, os, options.IntervalOf(ServerArea.Performance));
|
||||||
_previousRaw = raw;
|
_previousRaw = raw;
|
||||||
_samples = ServerPerformanceMath.Append(_samples, sample);
|
_samples = ServerPerformanceMath.Append(_samples, sample);
|
||||||
var snapshot = new ServerPerformanceSnapshot
|
var snapshot = new ServerPerformanceSnapshot
|
||||||
|
|
@ -295,7 +295,7 @@ public sealed partial class ServerMonitor(
|
||||||
ArgumentNullException.ThrowIfNull(session);
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
var tasks = options.Tasks;
|
var tasks = options.Tasks;
|
||||||
var read = await session.GetTasksAsync(
|
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);
|
Math.Max(1, tasks.MaxWatched), cancellationToken).ConfigureAwait(false);
|
||||||
var snapshot = new ServerTaskSnapshot { CapturedAt = now, Tasks = read.Watched, Visible = read.Visible, Coverage = read.Note };
|
var snapshot = new ServerTaskSnapshot { CapturedAt = now, Tasks = read.Watched, Visible = read.Visible, Coverage = read.Note };
|
||||||
_data.Set(snapshot);
|
_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")]
|
[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);
|
private static partial void LogCycle(ILogger logger, string server, int findings, int notMeasurable);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The rules' name matching (names, or prefixes ending in *), for the agent's task filter.</summary>
|
|
||||||
internal static class WindowsServerRulesAccess
|
|
||||||
{
|
|
||||||
public static bool Matches(string name, IEnumerable<string> patterns) => patterns.Any(p =>
|
|
||||||
p.EndsWith('*') ? name.StartsWith(p[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(p, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,12 @@ public sealed class ServiceThresholds
|
||||||
|
|
||||||
/// <summary>Right after a restart services are still starting: no finding for this many minutes.</summary>
|
/// <summary>Right after a restart services are still starting: no finding for this many minutes.</summary>
|
||||||
public int StartupGraceMinutes { get; set; } = 15;
|
public int StartupGraceMinutes { get; set; } = 15;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxFindings { get; set; } = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class EventLogThresholds
|
public sealed class EventLogThresholds
|
||||||
|
|
@ -82,7 +88,7 @@ public sealed class EventLogThresholds
|
||||||
/// <summary>Events left out as noise, in addition to the built-in list: "Kilde:id", "Kilde" or "Kilde*".</summary>
|
/// <summary>Events left out as noise, in addition to the built-in list: "Kilde:id", "Kilde" or "Kilde*".</summary>
|
||||||
public IList<string> Ignore { get; } = [];
|
public IList<string> Ignore { get; } = [];
|
||||||
|
|
||||||
/// <summary>At most this many event findings per server, most important first.</summary>
|
/// <summary>At most this many event findings per server, most important first; the rest are named in one summary finding. Between 1 and 100.</summary>
|
||||||
public int MaxFindings { get; set; } = 15;
|
public int MaxFindings { get; set; } = 15;
|
||||||
|
|
||||||
/// <summary>At most this many events per log are read in one collection; the rest is read next time.</summary>
|
/// <summary>At most this many events per log are read in one collection; the rest is read next time.</summary>
|
||||||
|
|
@ -100,8 +106,23 @@ public sealed class TaskThresholds
|
||||||
/// <summary>At most this many tasks per server are watched (their run information is read one at a time).</summary>
|
/// <summary>At most this many tasks per server are watched (their run information is read one at a time).</summary>
|
||||||
public int MaxWatched { get; set; } = 50;
|
public int MaxWatched { get; set; } = 50;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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; <see cref="TaskResults.DefaultIgnoredNames"/>). On by default.
|
||||||
|
/// </summary>
|
||||||
|
public bool IgnoreUserAndUpdaterTasks { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>Tasks never watched (path\name, or a prefix ending in *).</summary>
|
/// <summary>Tasks never watched (path\name, or a prefix ending in *).</summary>
|
||||||
public IList<string> Ignore { get; } = [];
|
public IList<string> Ignore { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether the task (its full path) is watched: the agent's filter when it reads tasks, and the rules' too.</summary>
|
||||||
|
public bool Watches(string fullName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fullName);
|
||||||
|
return (IncludeMicrosoftTasks || !TaskResults.IsMicrosoftTask(fullName))
|
||||||
|
&& !(IgnoreUserAndUpdaterTasks && TaskResults.IsUserOrUpdaterTask(fullName))
|
||||||
|
&& !WindowsServerRules.Matches(fullName, Ignore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What the Windows server rules see: the server, the latest data per area, and the thresholds.</summary>
|
/// <summary>What the Windows server rules see: the server, the latest data per area, and the thresholds.</summary>
|
||||||
|
|
|
||||||
|
|
@ -62,29 +62,43 @@ public sealed class SyntheticCheckFailingRule : ScopedRule<SyntheticCheckRuleCon
|
||||||
|
|
||||||
var check = context.Check;
|
var check = context.Check;
|
||||||
var latest = failures[0];
|
var latest = failures[0];
|
||||||
|
|
||||||
|
// Only the results the agent passed are known: when every one of them failed, the run of failures may be longer.
|
||||||
|
var all = failures.Count == context.Recent.Count;
|
||||||
|
var times = (all ? "mindst " : string.Empty) + failures.Count.ToString(CultureInfo.InvariantCulture);
|
||||||
var serviceRunning = string.Equals(latest.ServiceState, "Running", StringComparison.OrdinalIgnoreCase);
|
var serviceRunning = string.Equals(latest.ServiceState, "Running", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var serviceMissing = string.Equals(latest.ServiceState, NotFound, StringComparison.OrdinalIgnoreCase);
|
||||||
var serviceKnown = check.ServiceName is not null && latest.ServiceState is not null;
|
var serviceKnown = check.ServiceName is not null && latest.ServiceState is not null;
|
||||||
var title = serviceKnown && serviceRunning
|
|
||||||
? $"{check.Name} svarer ikke, selv om tjenesten {check.ServiceName} kører"
|
// The title goes to the API and to notifications (Teams, webhooks, e-mail) as it is: never the failure message,
|
||||||
: serviceKnown
|
// which can name hosts, logins and paths. That is in the evidence, marked sensitive.
|
||||||
? $"{check.Name} svarer ikke: tjenesten {check.ServiceName} er {StateText(latest.ServiceState!)}"
|
var title = !serviceKnown ? $"{check.Name} fejler ({times} gange i træk)"
|
||||||
: $"{check.Name} fejler: {latest.Message}";
|
: serviceRunning ? $"{check.Name} svarer ikke, selv om tjenesten {check.ServiceName} kører"
|
||||||
|
: serviceMissing ? $"{check.Name} svarer ikke: tjenesten {check.ServiceName} findes ikke"
|
||||||
|
: $"{check.Name} svarer ikke: tjenesten {check.ServiceName} er {StateText(latest.ServiceState!)}";
|
||||||
var explanation = What(check.Kind)
|
var explanation = What(check.Kind)
|
||||||
+ (serviceKnown && serviceRunning
|
+ (!serviceKnown ? string.Empty
|
||||||
? $" Tjenesten {check.ServiceName} på {check.ServerName} kører, så Windows ser alt som i orden, men den svarer ikke: den kan hænge, være overbelastet, "
|
: serviceRunning
|
||||||
+ "vente på databasen eller være blokeret af en firewall. Det er netop den slags fejl, en tjenesteovervågning alene ikke ser."
|
? $" Tjenesten {check.ServiceName} på {check.ServerName} kører, så Windows ser alt som i orden, men den svarer ikke: den kan hænge, være overbelastet, "
|
||||||
: serviceKnown ? $" Tjenesten {check.ServiceName} på {check.ServerName} kører ikke." : string.Empty);
|
+ "vente på databasen eller være blokeret af en firewall. Det er netop den slags fejl, en tjenesteovervågning alene ikke ser."
|
||||||
|
: serviceMissing
|
||||||
|
? $" Tjenesten {check.ServiceName} findes ikke på {check.ServerName} (eller agenten må ikke se den): den kan være afinstalleret eller omdøbt, "
|
||||||
|
+ "eller tjekket er knyttet til et forkert tjenestenavn."
|
||||||
|
: $" Tjenesten {check.ServiceName} på {check.ServerName} kører ikke.");
|
||||||
var evidence = new List<Evidence>
|
var evidence = new List<Evidence>
|
||||||
{
|
{
|
||||||
new("Fejl", latest.Message, Sensitive: true),
|
new("Fejl", latest.Message, Sensitive: true),
|
||||||
new("Mål", check.Target),
|
new("Mål", check.Target),
|
||||||
new("Fejlet i træk", failures.Count.ToString(CultureInfo.InvariantCulture)),
|
new("Fejlet i træk", times),
|
||||||
new("Fejler siden", SyntheticCheckRules.Time(failures[^1].At)),
|
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)),
|
new("Seneste forsøg", SyntheticCheckRules.Time(latest.At)),
|
||||||
};
|
};
|
||||||
if (check.ServiceName is not null)
|
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)));
|
evidence.AddRange(latest.Details.Select(d => new Evidence(d.Name, d.Value, d.Sensitive)));
|
||||||
|
|
@ -97,14 +111,20 @@ public sealed class SyntheticCheckFailingRule : ScopedRule<SyntheticCheckRuleCon
|
||||||
subject: "check"));
|
subject: "check"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The service state the agent records when the check's service does not exist on the server.</summary>
|
||||||
|
private const string NotFound = "NotFound";
|
||||||
|
|
||||||
private static string StateText(string state) => state switch
|
private static string StateText(string state) => state switch
|
||||||
{
|
{
|
||||||
"Running" => "kører",
|
"Running" => "kører",
|
||||||
"Stopped" => "stoppet",
|
"Stopped" => "stoppet",
|
||||||
"Start Pending" => "ved at starte",
|
"Start Pending" => "ved at starte",
|
||||||
"Stop Pending" => "ved at stoppe",
|
"Stop Pending" => "ved at stoppe",
|
||||||
|
"Continue Pending" => "ved at fortsætte",
|
||||||
|
"Pause Pending" => "ved at gå på pause",
|
||||||
"Paused" => "sat på pause",
|
"Paused" => "sat på pause",
|
||||||
_ => state,
|
NotFound => "findes ikke",
|
||||||
|
_ => $"i tilstanden \"{state}\"",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string What(string kind) => kind switch
|
private static string What(string kind) => kind switch
|
||||||
|
|
@ -194,6 +214,8 @@ public sealed class CertificateExpiryRule : ScopedRule<SyntheticCheckRuleContext
|
||||||
severity,
|
severity,
|
||||||
days <= 0
|
days <= 0
|
||||||
? $"Certifikatet for {context.Check.Name} er udløbet"
|
? $"Certifikatet for {context.Check.Name} er udløbet"
|
||||||
|
: days < 1 ? $"Certifikatet for {context.Check.Name} udløber inden for et døgn"
|
||||||
|
: days < 2 ? $"Certifikatet for {context.Check.Name} udløber om 1 dag"
|
||||||
: $"Certifikatet for {context.Check.Name} udløber om {Math.Floor(days).ToString(CultureInfo.InvariantCulture)} dage",
|
: $"Certifikatet for {context.Check.Name} udløber om {Math.Floor(days).ToString(CultureInfo.InvariantCulture)} dage",
|
||||||
"Når certifikatet udløber, afviser browsere og integrationer forbindelsen (eller viser en sikkerhedsadvarsel), og siden er i praksis nede.",
|
"Når certifikatet udløber, afviser browsere og integrationer forbindelsen (eller viser en sikkerhedsadvarsel), og siden er i praksis nede.",
|
||||||
"Forny certifikatet og bind det nye i webserveren (IIS: Bindinger på webstedet). Tjek også, om andre servere bruger samme certifikat.",
|
"Forny certifikatet og bind det nye i webserveren (IIS: Bindinger på webstedet). Tjek også, om andre servere bruger samme certifikat.",
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,26 @@ public static class WindowsServerRules
|
||||||
internal static Reference Learn(string title, string path) => new(title, new Uri("https://learn.microsoft.com/" + path));
|
internal static Reference Learn(string title, string path) => new(title, new Uri("https://learn.microsoft.com/" + path));
|
||||||
|
|
||||||
/// <summary>A name in a list where entries may end in * (prefix).</summary>
|
/// <summary>A name in a list where entries may end in * (prefix).</summary>
|
||||||
internal static bool Matches(string name, IEnumerable<string> patterns) => patterns.Any(p =>
|
internal static bool Matches(string name, IEnumerable<string> patterns) => patterns.Any(p => Matches(name, p));
|
||||||
p.EndsWith('*') ? name.StartsWith(p[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(p, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
private static bool Matches(string name, string pattern) =>
|
||||||
|
pattern.EndsWith('*') ? name.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsService(ServerService service, string pattern) => Matches(service.Name, pattern) || Matches(service.DisplayName, pattern);
|
||||||
|
|
||||||
|
internal static bool IsService(ServerService service, IEnumerable<string> patterns) => patterns.Any(p => IsService(service, p));
|
||||||
|
|
||||||
|
/// <summary>A "max findings" setting, kept between 1 and 100.</summary>
|
||||||
|
internal static int Bounded(int maxFindings) => Math.Clamp(maxFindings, 1, 100);
|
||||||
|
|
||||||
|
/// <summary>Names for the summary of the findings over the cap: at most 50, then "og N flere".</summary>
|
||||||
|
internal static string List(IReadOnlyCollection<string> names) =>
|
||||||
|
string.Join(", ", names.Take(50)) + (names.Count > 50 ? $" og {names.Count - 50} flere" : string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ServerCpuRule : ScopedRule<ServerRuleContext>
|
public sealed class ServerCpuRule : ScopedRule<ServerRuleContext>
|
||||||
|
|
@ -239,9 +257,11 @@ public sealed class DiskForecastRule : ScopedRule<ServerRuleContext>
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
var disks = context.Data.Get<ServerDiskSnapshot>();
|
var disks = context.Data.Get<ServerDiskSnapshot>();
|
||||||
var settings = context.Settings.Disks;
|
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
|
return Result(disks.Forecasts
|
||||||
|
|
@ -252,7 +272,9 @@ public sealed class DiskForecastRule : ScopedRule<ServerRuleContext>
|
||||||
var disk = disks.Disks.FirstOrDefault(d => string.Equals(d.Volume, f.Volume, StringComparison.OrdinalIgnoreCase));
|
var disk = disks.Disks.FirstOrDefault(d => string.Equals(d.Volume, f.Volume, StringComparison.OrdinalIgnoreCase));
|
||||||
return CreateFinding(
|
return CreateFinding(
|
||||||
days <= 2 ? Severity.Critical : days <= 7 ? Severity.High : Severity.Medium,
|
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 "
|
$"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 "
|
+ "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.",
|
+ "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<ServerRuleContext>
|
||||||
+ "backup-rotation faktisk kører.",
|
+ "backup-rotation faktisk kører.",
|
||||||
[
|
[
|
||||||
new Evidence("Fuldt om ca.", WindowsServerRules.Number(days, days < 10 ? 1 : 0) + " dage"),
|
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("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("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)}"),
|
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<ServerRuleContext>
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
var services = context.Data.Get<ServerServiceSnapshot>();
|
var services = context.Data.Get<ServerServiceSnapshot>();
|
||||||
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();
|
var ignore = StopByDesign.Concat(context.Settings.Services.Ignore).Concat(context.Server.IgnoredServices).ToList();
|
||||||
return Result(services.Services
|
var stopped = services.Services
|
||||||
.Where(s => s.IsAutomatic && !s.IsRunning && !WindowsServerRules.Matches(s.Name, ignore) && !WindowsServerRules.Matches(s.Name, context.Server.WatchedServices))
|
.Where(s => s.IsAutomatic && !s.IsRunning && !WindowsServerRules.IsService(s, ignore) && !WindowsServerRules.IsService(s, context.Server.WatchedServices))
|
||||||
.Select(s => CreateFinding(
|
.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,
|
Severity.Medium,
|
||||||
$"Tjenesten {s.DisplayName} ({s.Name}) kører ikke",
|
$"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)
|
$"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<ServerRuleContext>
|
||||||
new Evidence("Start", s.StartMode + (s.DelayedAutoStart ? " (forsinket)" : string.Empty)),
|
new Evidence("Start", s.StartMode + (s.DelayedAutoStart ? " (forsinket)" : string.Empty)),
|
||||||
new Evidence("Afslutningskode", s.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "–"),
|
new Evidence("Afslutningskode", s.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "–"),
|
||||||
],
|
],
|
||||||
subject: "service: " + s.Name)));
|
subject: "service: " + s.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool InGracePeriod(ServerRuleContext context) =>
|
/// <summary>
|
||||||
context.Data.TryGet<ServerPerformanceSnapshot>(out var performance) && performance.Os.LastBootUpTime is { } boot
|
/// Right after a restart services are still starting. Not "passed": that would resolve open findings at every restart
|
||||||
&& context.Now - boot < TimeSpan.FromMinutes(context.Settings.Services.StartupGraceMinutes);
|
/// and open them again a quarter of an hour later. Not measurable keeps them as they are.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? GraceReason(ServerRuleContext context)
|
||||||
|
{
|
||||||
|
if (!context.Data.TryGet<ServerPerformanceSnapshot>(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Services chosen for the server (the AOS, IIS, SQL Server …) that must run, whatever their start mode.</summary>
|
/// <summary>Services chosen for the server (the AOS, IIS, SQL Server …) that must run, whatever their start mode.</summary>
|
||||||
|
|
@ -333,29 +392,30 @@ public sealed class WatchedServiceRule : ScopedRule<ServerRuleContext>
|
||||||
}
|
}
|
||||||
|
|
||||||
var services = context.Data.Get<ServerServiceSnapshot>();
|
var services = context.Data.Get<ServerServiceSnapshot>();
|
||||||
if (StoppedAutomaticServiceRule.InGracePeriod(context))
|
if (StoppedAutomaticServiceRule.GraceReason(context) is { } grace)
|
||||||
{
|
{
|
||||||
return RuleEvaluation.Passed();
|
return RuleEvaluation.NotMeasurable(grace);
|
||||||
}
|
}
|
||||||
|
|
||||||
var findings = new List<Finding>();
|
var findings = new List<Finding>();
|
||||||
|
var reported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var name in context.Server.WatchedServices)
|
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));
|
var matching = services.Services.Where(s => WindowsServerRules.IsService(s, name)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
if (service is null)
|
if (matching.Count == 0)
|
||||||
{
|
{
|
||||||
findings.Add(CreateFinding(
|
findings.Add(CreateFinding(
|
||||||
Severity.Medium,
|
Severity.Medium,
|
||||||
$"Den overvågede tjeneste {name} findes ikke eller kan ikke ses",
|
$"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, "
|
"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).",
|
+ "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))],
|
[new Evidence("Tjeneste", name), new Evidence("Tjenester agenten kan se", services.Services.Count.ToString(CultureInfo.InvariantCulture))],
|
||||||
subject: "watched: " + name));
|
subject: "watched: " + name));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!service.IsRunning)
|
foreach (var service in matching.Where(s => !s.IsRunning && reported.Add(s.Name)))
|
||||||
{
|
{
|
||||||
findings.Add(CreateFinding(
|
findings.Add(CreateFinding(
|
||||||
Severity.High,
|
Severity.High,
|
||||||
|
|
@ -387,12 +447,34 @@ public sealed class WindowsEventLogRule : ScopedRule<ServerRuleContext>
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
var events = context.Data.Get<ServerEventSnapshot>();
|
var events = context.Data.Get<ServerEventSnapshot>();
|
||||||
var settings = context.Settings.EventLog;
|
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.UnexpectedShutdown)
|
||||||
.Where(g => g.Category != ServerEventCategory.Error || g.Count >= Math.Max(1, settings.RepeatThreshold))
|
.Where(g => g.Category != ServerEventCategory.Error || g.Count >= Math.Max(1, settings.RepeatThreshold))
|
||||||
.OrderByDescending(g => g.Category).ThenByDescending(g => g.Count)
|
.OrderByDescending(g => g.Category).ThenBy(g => g.FirstAt).ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
|
||||||
.Take(Math.Max(1, settings.MaxFindings))
|
.ToList();
|
||||||
.Select(g => CreateFinding(
|
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),
|
SeverityOf(g),
|
||||||
$"{ServerEventAnalysis.Display(g.Category)}: {g.Source} {g.EventId} ({g.Count} {(g.Count == 1 ? "gang" : "gange")} i {g.Log}-loggen)",
|
$"{ServerEventAnalysis.Display(g.Category)}: {g.Source} {g.EventId} ({g.Count} {(g.Count == 1 ? "gang" : "gange")} i {g.Log}-loggen)",
|
||||||
Explanation(g.Category),
|
Explanation(g.Category),
|
||||||
|
|
@ -407,7 +489,7 @@ public sealed class WindowsEventLogRule : ScopedRule<ServerRuleContext>
|
||||||
new Evidence("Seneste", WindowsServerRules.Time(g.LastAt)),
|
new Evidence("Seneste", WindowsServerRules.Time(g.LastAt)),
|
||||||
new Evidence("Eksempel", g.Sample ?? "(ingen tekst)", Sensitive: true),
|
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
|
private static Severity SeverityOf(ServerEventGroup group) => group.Category switch
|
||||||
|
|
@ -490,6 +572,27 @@ public sealed class ServerRestartRule : ScopedRule<ServerRuleContext>
|
||||||
subject: "restart"));
|
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(
|
return RuleEvaluation.Found(CreateFinding(
|
||||||
Severity.Info,
|
Severity.Info,
|
||||||
$"Serveren er genstartet {WindowsServerRules.Time(boot)}",
|
$"Serveren er genstartet {WindowsServerRules.Time(boot)}",
|
||||||
|
|
@ -544,21 +647,21 @@ public sealed class FailedScheduledTaskRule : ScopedRule<ServerRuleContext>
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
var tasks = context.Data.Get<ServerTaskSnapshot>();
|
var tasks = context.Data.Get<ServerTaskSnapshot>();
|
||||||
return Result(tasks.Tasks
|
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(
|
.Select(t => CreateFinding(
|
||||||
Severity.Medium,
|
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, "
|
"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.",
|
+ "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 "
|
"Å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.",
|
+ "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("Resultat", TaskResults.Describe(t.LastTaskResult.Value)),
|
||||||
new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)),
|
new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)),
|
||||||
new Evidence("Næste kørsel", WindowsServerRules.Time(t.NextRunTime)),
|
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<ServerRuleContext>
|
||||||
var late = TimeSpan.FromMinutes(Math.Max(1, context.Settings.Tasks.LateMinutes));
|
var late = TimeSpan.FromMinutes(Math.Max(1, context.Settings.Tasks.LateMinutes));
|
||||||
return Result(tasks.Tasks
|
return Result(tasks.Tasks
|
||||||
.Where(t => t.Enabled && !string.Equals(t.State, "Running", StringComparison.OrdinalIgnoreCase)
|
.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))))
|
&& (t.MissedRuns > 0 || (t.NextRunTime is { } next && next < context.Now - late && (t.LastRunTime is null || t.LastRunTime < next))))
|
||||||
.Select(t => CreateFinding(
|
.Select(t => CreateFinding(
|
||||||
Severity.Medium,
|
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 "
|
"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.",
|
+ "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\").",
|
"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("Planlagt", WindowsServerRules.Time(t.NextRunTime)),
|
||||||
new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)),
|
new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)),
|
||||||
new Evidence("Mistede kørsler", t.MissedRuns.ToString(CultureInfo.InvariantCulture)),
|
new Evidence("Mistede kørsler", t.MissedRuns.ToString(CultureInfo.InvariantCulture)),
|
||||||
new Evidence("Status", t.State),
|
new Evidence("Status", t.State),
|
||||||
],
|
],
|
||||||
subject: "task-missed: " + t.FullName)));
|
subject: "task-missed: " + TaskResults.Display(t.FullName))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,37 @@ public class WindowsServerRuleTests
|
||||||
var findings = Run(new DiskForecastRule(), Context(With(disks))).Findings;
|
var findings = Run(new DiskForecastRule(), Context(With(disks))).Findings;
|
||||||
|
|
||||||
Assert.Equal(2, findings.Count);
|
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.Contains(findings, f => f.Subject == @"forecast: E:\" && f.Severity == Severity.Critical);
|
||||||
Assert.Equal(RuleOutcome.NotMeasurable, Run(new DiskForecastRule(), Context(With(disks with { Forecasts = [] }))).Outcome);
|
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 };
|
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")));
|
var data = With(Services(new ServerService("AOS60$01", "AOS", "Stopped", "Auto")));
|
||||||
data.Set(Performance(_ => Sample(), boot: Now.AddMinutes(-5)));
|
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]
|
[Fact]
|
||||||
|
|
@ -218,13 +291,36 @@ public class WindowsServerRuleTests
|
||||||
Recent = Enumerable.Range(1, 40).Select(i => Group("App" + i, i, ServerEventCategory.ApplicationCrash, 1)).ToList(),
|
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<int, int> 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]
|
[Fact]
|
||||||
public void Restart_unexpected_with_a_crash_event_otherwise_info()
|
public void Restart_unexpected_with_a_crash_event_otherwise_info()
|
||||||
{
|
{
|
||||||
var data = With(Performance(_ => Sample(), boot: Now.AddHours(-3)));
|
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));
|
var planned = Run(new ServerRestartRule(), Context(data));
|
||||||
data.Set(new ServerEventSnapshot
|
data.Set(new ServerEventSnapshot
|
||||||
{
|
{
|
||||||
|
|
@ -240,6 +336,24 @@ public class WindowsServerRuleTests
|
||||||
Assert.Equal(RuleOutcome.Passed, old.Outcome);
|
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]
|
[Fact]
|
||||||
public void Uptime_rule_is_off_by_default()
|
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: \AX export", Assert.Single(failed.Findings).Subject);
|
||||||
Assert.Equal([@"task-missed: \Firma\Late", @"task-missed: \Missed"], missed.Findings.Select(f => f.Subject).ToList());
|
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
|
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);
|
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));
|
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(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);
|
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.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.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("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]
|
[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(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.NotApplicable, Run(new CertificateExpiryRule(), Check(), Result(true, 0)).Outcome);
|
||||||
Assert.Equal(RuleOutcome.NotMeasurable, Run(new CertificateExpiryRule(), check, Result(false, 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue