From ab8cfcad975816ddeb4bd2db4bb7f365a2d23842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 08:12:26 +0200 Subject: [PATCH] Configuration page with change log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new collector instance-config: collation, edition, version, services and accounts (sys.dm_server_services, 2008 R2 SP1+), Instant File Initialization and Lock Pages in Memory (where the build has the columns), tempdb files, default paths; plus a fingerprint of the whole setup (sp_configure, trace flags, services, version, tempdb files) - changes are computed against the previous collection, which the agent restores from the repository after a restart; migration 0008 keeps them in insight.ConfigurationChanges (as long as resolved findings) - SQLI-CFG-013 "Konfiguration ændret": information for a day when sp_configure, a trace flag or a service account changes (account names are sensitive evidence) - page /instanser/{navn}/konfiguration: instance, CPU and memory, services, tempdb, trace flags, non-default sp_configure (default vs. in use vs. configured) and the change log; linked from the instance page - the management report lists configuration changes in the period - trace flag reading shared by trace-flags and instance-config Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/konfiguration.md | 21 ++ docs/rapporter.md | 1 + .../Monitoring/InstanceMonitor.cs | 6 + .../BuiltInCollectors.cs | 1 + .../InstanceConfigurationCollector.cs | 101 +++++++++ .../Instance/TraceFlagCollector.cs | 13 +- .../Data/InstanceConfiguration.cs | 198 ++++++++++++++++++ src/SQLInsight.Reports/ManagementReport.cs | 28 +++ src/SQLInsight.Reports/ReportData.cs | 4 + .../InsightRepository.Configuration.cs | 21 ++ .../InsightRepository.Maintenance.cs | 1 + .../InsightRepository.cs | 47 ++++- .../Migrations/0008_configuration_changes.sql | 13 ++ src/SQLInsight.Rules/BuiltInRules.cs | 1 + .../Configuration/ConfigurationRules.cs | 42 ++++ .../Components/Pages/Configuration.razor | 192 +++++++++++++++++ .../Components/Pages/Instance.razor | 3 + src/SQLInsight.Web/wwwroot/app.css | 3 + .../ConfigurationFingerprintTests.cs | 69 ++++++ .../CollectorTests.cs | 26 +++ .../ConfigurationPageTests.cs | 79 +++++++ .../ConfigurationRuleTests.cs | 26 +++ .../RuleCatalogTests.cs | 2 +- 24 files changed, 888 insertions(+), 12 deletions(-) create mode 100644 docs/konfiguration.md create mode 100644 src/SQLInsight.Collectors/Instance/InstanceConfigurationCollector.cs create mode 100644 src/SQLInsight.Core/Data/InstanceConfiguration.cs create mode 100644 src/SQLInsight.Repository/InsightRepository.Configuration.cs create mode 100644 src/SQLInsight.Repository/Migrations/0008_configuration_changes.sql create mode 100644 src/SQLInsight.Web/Components/Pages/Configuration.razor create mode 100644 tests/SQLInsight.Core.Tests/ConfigurationFingerprintTests.cs create mode 100644 tests/SQLInsight.IntegrationTests/ConfigurationPageTests.cs diff --git a/README.md b/README.md index f6e418d..cf14555 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA. -> **Status:** Fase 1–4 er implementeret: agent, 61 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md). +> **Status:** Fase 1–4 er implementeret: agent, 62 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md). --- diff --git a/docs/konfiguration.md b/docs/konfiguration.md new file mode 100644 index 0000000..9dc03a1 --- /dev/null +++ b/docs/konfiguration.md @@ -0,0 +1,21 @@ +# Konfiguration og ændringslog + +Hver instans har en side **Konfiguration** (knappen øverst på instansens side) med, hvordan instansen er sat op: + +| Afsnit | Hvad | +|---|---| +| Instans | Version, edition, collation og standardmapper til data og log (SQL Server 2012+) | +| CPU og hukommelse | Logiske CPU'er, schedulers, NUMA-noder, fysisk hukommelse, virtuel maskine, max/min server memory, Lock Pages in Memory, Instant File Initialization, MAXDOP og cost threshold for parallelism | +| Tjenester | SQL Server- og SQL Agent-tjenesterne med konto, start og status (`sys.dm_server_services`, SQL Server 2008 R2 SP1+) | +| Tempdb | Filerne med størrelse, autogrowth og maksimum | +| Trace flags | Globalt slåede trace flags | +| Indstillinger, der afviger fra standard | sp_configure-værdier, der ikke står på SQL Servers standard, og værdier, der venter på `RECONFIGURE` eller genstart | +| Ændringslog | Alt, hvad der er ændret (7, 30, 90 dage eller 1 år) | + +Lock Pages in Memory og Instant File Initialization kan kun aflæses fra SQL Server 2012 SP4 og 2016 SP1. På ældre versioner står der "kan ikke aflæses". + +## Ændringer + +Agenten læser konfigurationen hvert 15. minut (collector `instance-config`) og sammenligner med sidste gang, også på tværs af en genstart af agenten. Ændringer af sp_configure, trace flags og tjenestekonti giver fundet **SQLI-CFG-013 Konfiguration ændret** (information, i et døgn), og alle ændringer, også version og antal tempdb-filer, står i ændringsloggen og i driftsrapporten. Den første måling efter installationen er udgangspunktet. + +Tidspunktet er, hvornår ændringen blev opdaget, ikke hvornår den blev lavet. Ændringsloggen gemmes lige så længe som løste fund (`SQLInsight:Retention:ResolvedFindingDays`, standard 365 dage). diff --git a/docs/rapporter.md b/docs/rapporter.md index 79886d2..7dd2c73 100644 --- a/docs/rapporter.md +++ b/docs/rapporter.md @@ -9,6 +9,7 @@ SQLInsight Pro laver en **driftsrapport** som PDF til ledelse, it-chef og revisi - **Kontroller** til compliance: supporteret version, opdateret, fuld backup, log-backup, integritetstjek, sidekontrol, tilgængelighed, hærdning (xp_cmdshell, TRUSTWORTHY) og vedligeholdelsesjobs. For hver kontrol vises, hvor mange instanser der opfylder den, ikke opfylder den, eller ikke kunne måles, og hvilke instanser der ikke opfylder den. - **Instanser** med version, CU-niveau og antal fund. - **Kritiske fund og fund med høj alvor** med alder. +- **Konfigurationsændringer i perioden**: sp_configure, trace flags, tjenestekonti, version og antal tempdb-filer, med fra/til og hvornår ændringen blev opdaget (se [konfiguration.md](konfiguration.md)). "Ikke målt" betyder, at SQL Server-versionen, rettighederne eller en fejl forhindrede målingen. Det tælles aldrig som opfyldt. diff --git a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs index bfa5c9c..12a5ce2 100644 --- a/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs +++ b/src/SQLInsight.Agent/Monitoring/InstanceMonitor.cs @@ -190,6 +190,12 @@ public sealed partial class InstanceMonitor( /// private async Task SeedAsync(ICollector collector, int instanceId, DateTimeOffset now, CancellationToken cancellationToken) { + // Changes are found against the stored state, so one made while the agent was down is still reported. + if (collector is Collectors.Instance.InstanceConfigurationCollector) + { + return await repository.GetSnapshotAsync(instanceId, collector.Name, cancellationToken).ConfigureAwait(false); + } + if (collector is not Collectors.Concurrency.DeadlockCollector) { return null; diff --git a/src/SQLInsight.Collectors/BuiltInCollectors.cs b/src/SQLInsight.Collectors/BuiltInCollectors.cs index c6c6574..195315f 100644 --- a/src/SQLInsight.Collectors/BuiltInCollectors.cs +++ b/src/SQLInsight.Collectors/BuiltInCollectors.cs @@ -13,6 +13,7 @@ public static class BuiltInCollectors new ServerCollector(), new ConfigurationCollector(), new TraceFlagCollector(), + new InstanceConfigurationCollector(), new DatabaseCollector(), new SuspectPageCollector(), new PlanCacheCollector(), diff --git a/src/SQLInsight.Collectors/Instance/InstanceConfigurationCollector.cs b/src/SQLInsight.Collectors/Instance/InstanceConfigurationCollector.cs new file mode 100644 index 0000000..c5e15b3 --- /dev/null +++ b/src/SQLInsight.Collectors/Instance/InstanceConfigurationCollector.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; + +namespace SQLInsight.Collectors.Instance; + +/// +/// The instance's setup for the configuration page and its change log: collation, services and accounts, Instant File +/// Initialization, Lock Pages in Memory, tempdb files and default paths, plus sp_configure and trace flags for +/// the fingerprint. Changes are computed against the previous collection, which the agent restores from the repository +/// after a restart, so a change made while the agent was down is still reported. +/// +public sealed class InstanceConfigurationCollector : Collector +{ + // SERVERPROPERTY returns NULL for properties an older version does not know, so this runs everywhere. + internal const string PropertiesSql = """ + SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), CAST(SERVERPROPERTY('ProductLevel') AS nvarchar(128)), + CAST(SERVERPROPERTY('Edition') AS nvarchar(128)), CAST(SERVERPROPERTY('Collation') AS nvarchar(128)), + CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS nvarchar(260)), CAST(SERVERPROPERTY('InstanceDefaultLogPath') AS nvarchar(260)) + """; + + // Which optional columns this build has: Instant File Initialization and the memory model came in service packs. + internal const string ColumnsSql = """ + SELECT COL_LENGTH('sys.dm_server_services', 'instant_file_initialization_enabled'), COL_LENGTH('sys.dm_os_sys_info', 'sql_memory_model_desc') + """; + + internal const string TempdbSql = """ + SELECT name, type_desc, CAST(size AS bigint) / 128, growth, is_percent_growth, max_size + FROM sys.master_files WHERE database_id = 2 ORDER BY file_id + """; + + public override string Name => "instance-config"; + public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15); + + protected override async Task CollectAsync(CollectorContext context, InstanceConfigurationSnapshot? previous) + { + var ct = context.CancellationToken; + var connection = context.Connection; + var properties = (await Db.QueryAsync(connection, PropertiesSql, r => (Version: r.NullableString(0), Level: r.NullableString(1), + Edition: r.NullableString(2), Collation: r.NullableString(3), DataPath: r.NullableString(4), LogPath: r.NullableString(5)), ct).ConfigureAwait(false)).FirstOrDefault(); + + var snapshot = new InstanceConfigurationSnapshot + { + CapturedAt = context.Now, + ProductVersion = properties.Version, + ProductLevel = properties.Level, + Edition = properties.Edition, + Collation = properties.Collation, + DefaultDataPath = properties.DataPath, + DefaultLogPath = properties.LogPath, + }; + + if (context.Instance.Version.Major >= 9) + { + var tempdb = await Db.QueryAsync(connection, TempdbSql, r => new TempdbFile( + r.String(0), r.String(1), r.Long(2), Growth(r.Long(3), r.Bool(4)), MaxSize(r.Long(5))), ct).ConfigureAwait(false); + snapshot = snapshot with { TempdbFiles = tempdb }; + } + + if (context.Capabilities.Has(Capability.VolumeStats)) + { + var columns = (await Db.QueryAsync(connection, ColumnsSql, r => (Ifi: r.NullableInt(0) is not null, Memory: r.NullableInt(1) is not null), ct).ConfigureAwait(false)).FirstOrDefault(); + var ifi = columns.Ifi ? "instant_file_initialization_enabled" : "CAST(NULL AS nvarchar(1))"; + var services = await Db.QueryAsync(connection, + $"SELECT servicename, service_account, startup_type_desc, status_desc, {ifi} FROM sys.dm_server_services", + r => new ServiceInfo(r.String(0), r.NullableString(1), r.NullableString(2), r.NullableString(3), YesNo(r.NullableString(4))), ct).ConfigureAwait(false); + var memoryModel = columns.Memory + ? (await Db.QueryAsync(connection, "SELECT sql_memory_model_desc FROM sys.dm_os_sys_info", r => r.NullableString(0), ct).ConfigureAwait(false)).FirstOrDefault() + : null; + snapshot = snapshot with + { + Services = services, + ServicesAvailable = true, + InstantFileInitialization = services.FirstOrDefault(s => s.Name.StartsWith("SQL Server (", StringComparison.OrdinalIgnoreCase))?.InstantFileInitialization, + MemoryModel = memoryModel, + }; + } + + var options = await Db.QueryAsync(connection, context.Instance.Version.Major >= 9 ? ConfigurationCollector.Sql2005 : ConfigurationCollector.Sql2000, + r => new ConfigurationOption(r.String(0), r.Long(1), r.Long(2)), ct).ConfigureAwait(false); + var flags = await TraceFlagCollector.ReadGlobalFlagsAsync(connection, ct).ConfigureAwait(false); + + var items = ConfigurationFingerprint.Items(snapshot, options, flags); + var changes = previous is null ? [] : ConfigurationFingerprint.Diff(previous.Items, items, context.Now); + var recent = (previous?.RecentChanges ?? []).Where(c => context.Now - c.DetectedAt < ConfigurationFingerprint.RecentWindow).Concat(changes).ToList(); + return snapshot with { Items = items, Changes = changes, RecentChanges = recent }; + } + + private static string Growth(long growth, bool percent) => + growth == 0 ? "ingen" : percent ? $"{growth} %" : $"{(growth / 128).ToString(CultureInfo.InvariantCulture)} MB"; + + private static string MaxSize(long maxSize) => + maxSize is -1 or 268435456 ? "ubegrænset" : $"{(maxSize / 128).ToString(CultureInfo.InvariantCulture)} MB"; + + private static bool? YesNo(string? value) => value switch + { + "Y" or "y" => true, + "N" or "n" => false, + _ => null, + }; +} diff --git a/src/SQLInsight.Collectors/Instance/TraceFlagCollector.cs b/src/SQLInsight.Collectors/Instance/TraceFlagCollector.cs index effb528..e93b0db 100644 --- a/src/SQLInsight.Collectors/Instance/TraceFlagCollector.cs +++ b/src/SQLInsight.Collectors/Instance/TraceFlagCollector.cs @@ -1,3 +1,4 @@ +using System.Data.Common; using SQLInsight.Core.Data; namespace SQLInsight.Collectors.Instance; @@ -7,15 +8,19 @@ public sealed class TraceFlagCollector : Collector public override string Name => "trace-flags"; public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15); - protected override async Task CollectAsync(CollectorContext context, TraceFlagSnapshot? previous) + protected override async Task CollectAsync(CollectorContext context, TraceFlagSnapshot? previous) => + new(context.Now, await ReadGlobalFlagsAsync(context.Connection, context.CancellationToken).ConfigureAwait(false)); + + /// The globally enabled trace flags, in order. + internal static async Task> ReadGlobalFlagsAsync(DbConnection connection, CancellationToken cancellationToken) { // 2005+: TraceFlag, Status, Global, Session. 2000: TraceFlag, Status (only global flags are listed with -1). var rows = await Db.QueryAsync( - context.Connection, + connection, "DBCC TRACESTATUS(-1) WITH NO_INFOMSGS", r => (Flag: r.Int(0), Enabled: r.Bool(1), Global: r.FieldCount < 3 || r.Bool(2)), - context.CancellationToken).ConfigureAwait(false); + cancellationToken).ConfigureAwait(false); - return new TraceFlagSnapshot(context.Now, rows.Where(r => r.Enabled && r.Global).Select(r => r.Flag).Distinct().Order().ToList()); + return rows.Where(r => r.Enabled && r.Global).Select(r => r.Flag).Distinct().Order().ToList(); } } diff --git a/src/SQLInsight.Core/Data/InstanceConfiguration.cs b/src/SQLInsight.Core/Data/InstanceConfiguration.cs new file mode 100644 index 0000000..df0956d --- /dev/null +++ b/src/SQLInsight.Core/Data/InstanceConfiguration.cs @@ -0,0 +1,198 @@ +using System.Globalization; + +namespace SQLInsight.Core.Data; + +/// +/// How the instance is set up, beyond sp_configure: collation, services and their accounts, Instant File +/// Initialization, Lock Pages in Memory, tempdb files and default paths. Also a flat "fingerprint" of the whole setup +/// (including sp_configure and trace flags), so changes between two collections can be listed. +/// +public sealed record InstanceConfigurationSnapshot +{ + public required DateTimeOffset CapturedAt { get; init; } + public string? ProductVersion { get; init; } + public string? ProductLevel { get; init; } + public string? Edition { get; init; } + public string? Collation { get; init; } + + /// SQL Server and SQL Server Agent services (sys.dm_server_services, 2008 R2 SP1+); empty before. + public IReadOnlyList Services { get; init; } = []; + + /// Whether the services could be read at all (the DMV exists). + public bool ServicesAvailable { get; init; } + + /// Instant File Initialization for the database engine service (2012 SP4, 2016 SP1+); null when unknown. + public bool? InstantFileInitialization { get; init; } + + /// CONVENTIONAL, LOCK_PAGES or LARGE_PAGES (2012 SP4, 2016 SP1+); null when unknown. + public string? MemoryModel { get; init; } + + public IReadOnlyList TempdbFiles { get; init; } = []; + + /// Default data and log folders for new databases (2012+); null when unknown. + public string? DefaultDataPath { get; init; } + + public string? DefaultLogPath { get; init; } + + /// The setup as comparable items: what is computed from. + public IReadOnlyList Items { get; init; } = []; + + /// What changed since the previous collection (empty on the first). + public IReadOnlyList Changes { get; init; } = []; + + /// Changes detected within the last , for the finding. + public IReadOnlyList RecentChanges { get; init; } = []; +} + +public sealed record ServiceInfo(string Name, string? Account, string? StartupType, string? Status, bool? InstantFileInitialization); + +/// "64 MB" or "10 %"; "ingen" when autogrowth is off. +/// "ubegrænset" or a size. +public sealed record TempdbFile(string Name, string Type, long SizeMb, string Growth, string MaxSize); + +/// sp_configure, Trace flag, Tjenestekonto, Instans, Tempdb, Hukommelse. +public sealed record ConfigurationItem(string Area, string Name, string Value); + +/// Null when the item is new. +/// Null when the item was removed (a trace flag turned off, a service gone). +public sealed record ConfigurationChange(string Area, string Name, string? OldValue, string? NewValue, DateTimeOffset DetectedAt); + +public static class ConfigurationFingerprint +{ + public const string SpConfigure = "sp_configure"; + public const string TraceFlag = "Trace flag"; + public const string ServiceAccount = "Tjenestekonto"; + public const string Instance = "Instans"; + public const string Tempdb = "Tempdb"; + public const string Memory = "Hukommelse"; + + /// How long a change keeps its "Konfiguration ændret" finding open. + public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24); + + /// Areas whose changes are findings: settings someone deliberately changed. + public static bool IsNotable(string area) => area is SpConfigure or TraceFlag or ServiceAccount; + + /// Items present, changed or removed between two fingerprints. Order: area, then name. + public static IReadOnlyList Diff(IReadOnlyList before, IReadOnlyList after, DateTimeOffset at) + { + ArgumentNullException.ThrowIfNull(before); + ArgumentNullException.ThrowIfNull(after); + var old = before.GroupBy(Key, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var now = after.GroupBy(Key, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var changes = new List(); + foreach (var (key, item) in now) + { + if (!old.TryGetValue(key, out var was)) + { + changes.Add(new ConfigurationChange(item.Area, item.Name, null, item.Value, at)); + } + else if (!string.Equals(was.Value, item.Value, StringComparison.Ordinal)) + { + changes.Add(new ConfigurationChange(item.Area, item.Name, was.Value, item.Value, at)); + } + } + + changes.AddRange(old.Where(o => !now.ContainsKey(o.Key)).Select(o => new ConfigurationChange(o.Value.Area, o.Value.Name, o.Value.Value, null, at))); + return changes.OrderBy(c => c.Area, StringComparer.Ordinal).ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + static string Key(ConfigurationItem i) => i.Area + "\u001f" + i.Name; + } + + /// The fingerprint of sp_configure, trace flags and the instance-level settings. + public static IReadOnlyList Items(InstanceConfigurationSnapshot instance, IReadOnlyList options, IReadOnlyList traceFlags) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(traceFlags); + var items = new List(); + items.AddRange(options.Select(o => new ConfigurationItem(SpConfigure, o.Name, o.ValueInUse.ToString(CultureInfo.InvariantCulture)))); + items.AddRange(traceFlags.Select(f => new ConfigurationItem(TraceFlag, f.ToString(CultureInfo.InvariantCulture), "til"))); + items.AddRange(instance.Services.Select(s => new ConfigurationItem(ServiceAccount, s.Name, s.Account ?? "?"))); + Add(Instance, "Version", instance.ProductVersion is null ? null : $"{instance.ProductVersion} {instance.ProductLevel}".Trim()); + Add(Instance, "Edition", instance.Edition); + Add(Instance, "Collation", instance.Collation); + if (instance.TempdbFiles.Count > 0) + { + Add(Tempdb, "Antal datafiler", instance.TempdbFiles.Count(f => f.Type != "LOG").ToString(CultureInfo.InvariantCulture)); + } + + Add(Memory, "Lock Pages in Memory", instance.MemoryModel); + Add(Memory, "Instant File Initialization", instance.InstantFileInitialization switch { true => "til", false => "fra", null => null }); + return items; + + void Add(string area, string name, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + items.Add(new ConfigurationItem(area, name, value)); + } + } + } + + /// + /// SQL Server's default for an sp_configure option, so non-default values can be shown; null when not known. + /// Defaults are the documented ones for the database engine (the same across 2005–2022 unless noted). + /// + public static long? DefaultValue(string option) => Defaults.TryGetValue(option, out var value) ? value : null; + + private static readonly Dictionary Defaults = new(StringComparer.OrdinalIgnoreCase) + { + ["access check cache bucket count"] = 0, + ["access check cache quota"] = 0, + ["Ad Hoc Distributed Queries"] = 0, + ["affinity I/O mask"] = 0, + ["affinity mask"] = 0, + ["allow updates"] = 0, + ["backup checksum default"] = 0, + ["backup compression default"] = 0, + ["blocked process threshold (s)"] = 0, + ["c2 audit mode"] = 0, + ["clr enabled"] = 0, + ["clr strict security"] = 1, + ["common criteria compliance enabled"] = 0, + ["contained database authentication"] = 0, + ["cost threshold for parallelism"] = 5, + ["cross db ownership chaining"] = 0, + ["cursor threshold"] = -1, + ["Database Mail XPs"] = 0, + ["default full-text language"] = 1033, + ["default trace enabled"] = 1, + ["disallow results from triggers"] = 0, + ["external scripts enabled"] = 0, + ["filestream access level"] = 0, + ["fill factor (%)"] = 0, + ["index create memory (KB)"] = 0, + ["lightweight pooling"] = 0, + ["locks"] = 0, + ["max degree of parallelism"] = 0, + ["max full-text crawl range"] = 4, + ["max server memory (MB)"] = 2147483647, + ["max text repl size (B)"] = 65536, + ["max worker threads"] = 0, + ["min memory per query (KB)"] = 1024, + ["min server memory (MB)"] = 0, + ["nested triggers"] = 1, + ["network packet size (B)"] = 4096, + ["Ole Automation Procedures"] = 0, + ["open objects"] = 0, + ["optimize for ad hoc workloads"] = 0, + ["priority boost"] = 0, + ["query governor cost limit"] = 0, + ["query wait (s)"] = -1, + ["recovery interval (min)"] = 0, + ["remote access"] = 1, + ["remote admin connections"] = 0, + ["remote login timeout (s)"] = 10, + ["remote proc trans"] = 0, + ["remote query timeout (s)"] = 600, + ["scan for startup procs"] = 0, + ["server trigger recursion"] = 1, + ["show advanced options"] = 0, + ["SMO and DMO XPs"] = 1, + ["transform noise words"] = 0, + ["two digit year cutoff"] = 2049, + ["user connections"] = 0, + ["user options"] = 0, + ["xp_cmdshell"] = 0, + }; +} diff --git a/src/SQLInsight.Reports/ManagementReport.cs b/src/SQLInsight.Reports/ManagementReport.cs index 49fbc3b..8b52d93 100644 --- a/src/SQLInsight.Reports/ManagementReport.cs +++ b/src/SQLInsight.Reports/ManagementReport.cs @@ -54,6 +54,7 @@ public static class ManagementReport Controls(section, data); Instances(section, data); Findings(section, data); + ConfigurationChanges(section, data); About(section, data); var renderer = new PdfDocumentRenderer { Document = document }; @@ -273,6 +274,33 @@ public static class ManagementReport } } + private static void ConfigurationChanges(Section section, ReportData data) + { + section.AddParagraph("Konfigurationsændringer i perioden", StyleNames.Heading1); + section.AddParagraph("Ændringer af serverindstillinger (sp_configure), trace flags, tjenestekonti, version og tempdb, som SQLInsight Pro har registreret. Tidspunktet er, hvornår ændringen blev opdaget (højst 15 minutter efter).").Style = "Muted"; + if (data.ConfigurationChanges.Count == 0) + { + section.AddParagraph("Ingen."); + return; + } + + var table = Table(section, ("Opdaget", 2.6), ("Instans", 3.2), ("Indstilling", 5.4), ("Fra", 2.6), ("Til", 2.6)); + foreach (var c in data.ConfigurationChanges.Take(MaxFindingRows)) + { + var row = table.AddRow(); + row.Cells[0].AddParagraph(Date(data, new DateTimeOffset(System.DateTime.SpecifyKind(c.DetectedAt, DateTimeKind.Utc)))); + row.Cells[1].AddParagraph(c.InstanceName); + row.Cells[2].AddParagraph($"{c.Area}: {c.Name}"); + row.Cells[3].AddParagraph(c.OldValue ?? "–"); + row.Cells[4].AddParagraph(c.NewValue ?? "–"); + } + + if (data.ConfigurationChanges.Count > MaxFindingRows) + { + section.AddParagraph($"… og {data.ConfigurationChanges.Count - MaxFindingRows} flere. Se dem under Konfiguration på instansens side.").Style = "Muted"; + } + } + private static void About(Section section, ReportData data) { section.AddParagraph("Om rapporten", StyleNames.Heading1); diff --git a/src/SQLInsight.Reports/ReportData.cs b/src/SQLInsight.Reports/ReportData.cs index 5443d65..fed4a12 100644 --- a/src/SQLInsight.Reports/ReportData.cs +++ b/src/SQLInsight.Reports/ReportData.cs @@ -19,6 +19,9 @@ public sealed record ReportData public required PeriodStats Period { get; init; } public string? RulePack { get; init; } + /// Configuration changes detected in the period (sp_configure, trace flags, service accounts …). + public IReadOnlyList ConfigurationChanges { get; init; } = []; + public string Version { get; init; } = typeof(ReportData).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0.0.0"; @@ -43,6 +46,7 @@ public sealed record ReportData RuleStates = await repository.GetAllRuleStatesAsync(cancellationToken).ConfigureAwait(false), Period = await repository.GetPeriodStatsAsync(from, to, cancellationToken).ConfigureAwait(false), RulePack = (await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false))?.RulePackVersion, + ConfigurationChanges = await repository.GetConfigurationChangesAsync(null, from, to, 500, cancellationToken).ConfigureAwait(false), }; } } diff --git a/src/SQLInsight.Repository/InsightRepository.Configuration.cs b/src/SQLInsight.Repository/InsightRepository.Configuration.cs new file mode 100644 index 0000000..8d651ff --- /dev/null +++ b/src/SQLInsight.Repository/InsightRepository.Configuration.cs @@ -0,0 +1,21 @@ +using Dapper; + +namespace SQLInsight.Repository; + +public sealed record StoredConfigurationChange(long ChangeId, int InstanceId, string InstanceName, DateTime DetectedAt, string Area, string Name, string? OldValue, string? NewValue); + +public sealed partial class InsightRepository +{ + /// Configuration changes, newest first: for one instance (or all), detected in [from, to]. + public async Task> GetConfigurationChangesAsync(int? instanceId, DateTimeOffset from, DateTimeOffset to, int top = 500, CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + var rows = await connection.QueryAsync(""" + SELECT TOP (@top) c.ChangeId, c.InstanceId, i.Name AS InstanceName, c.DetectedAt, c.Area, c.Name, c.OldValue, c.NewValue + FROM insight.ConfigurationChanges c JOIN insight.Instances i ON i.InstanceId = c.InstanceId + WHERE (@instanceId IS NULL OR c.InstanceId = @instanceId) AND c.DetectedAt >= @from AND c.DetectedAt <= @to + ORDER BY c.DetectedAt DESC, c.ChangeId DESC; + """, new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false); + return rows.ToList(); + } +} diff --git a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs index 3a3f3d3..9630c06 100644 --- a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs +++ b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs @@ -89,6 +89,7 @@ public sealed partial class InsightRepository await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false); + await DeleteInBatchesAsync(connection, "insight.ConfigurationChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false); await DeleteInBatchesAsync(connection, "insight.OutboundSubmissions", "COALESCE(SentAt, GaveUpAt) < @cutoff", utc.AddDays(-7)).ConfigureAwait(false); } diff --git a/src/SQLInsight.Repository/InsightRepository.cs b/src/SQLInsight.Repository/InsightRepository.cs index 93a82e3..b5ad7a2 100644 --- a/src/SQLInsight.Repository/InsightRepository.cs +++ b/src/SQLInsight.Repository/InsightRepository.cs @@ -252,17 +252,52 @@ public sealed partial class InsightRepository(string connectionString) case CumulativeSample: // Never as a JSON snapshot: it holds statement texts. return; + case InstanceConfigurationSnapshot configuration: + await SaveConfigurationChangesAsync(connection, instanceId, configuration.Changes).ConfigureAwait(false); + await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false); + return; default: - await connection.ExecuteAsync(""" - MERGE insight.Snapshots AS t - USING (SELECT @instanceId AS InstanceId, @kind AS Kind) AS s ON t.InstanceId = s.InstanceId AND t.Kind = s.Kind - WHEN MATCHED THEN UPDATE SET CapturedAt = SYSUTCDATETIME(), Json = @json - WHEN NOT MATCHED THEN INSERT (InstanceId, Kind, CapturedAt, Json) VALUES (@instanceId, @kind, SYSUTCDATETIME(), @json); - """, new { instanceId, kind, json = JsonSerializer.Serialize(data, data.GetType(), Json) }).ConfigureAwait(false); + await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false); return; } } + /// The latest result of a collector, as JSON: the agent reads it back after a restart, the web UI for display. + private static Task SaveSnapshotJsonAsync(SqlConnection connection, int instanceId, string kind, object data) => + connection.ExecuteAsync(""" + MERGE insight.Snapshots AS t + USING (SELECT @instanceId AS InstanceId, @kind AS Kind) AS s ON t.InstanceId = s.InstanceId AND t.Kind = s.Kind + WHEN MATCHED THEN UPDATE SET CapturedAt = SYSUTCDATETIME(), Json = @json + WHEN NOT MATCHED THEN INSERT (InstanceId, Kind, CapturedAt, Json) VALUES (@instanceId, @kind, SYSUTCDATETIME(), @json); + """, new { instanceId, kind, json = JsonSerializer.Serialize(data, data.GetType(), Json) }); + + private static async Task SaveConfigurationChangesAsync(SqlConnection connection, int instanceId, IReadOnlyList changes) + { + if (changes.Count == 0) + { + return; + } + + await connection.ExecuteAsync(""" + INSERT insight.ConfigurationChanges (InstanceId, DetectedAt, Area, Name, OldValue, NewValue) + SELECT @instanceId, DetectedAt, Area, Name, OldValue, NewValue + FROM OPENJSON(@json) WITH (DetectedAt datetime2(0), Area nvarchar(40), Name nvarchar(260), OldValue nvarchar(400), NewValue nvarchar(400)); + """, new + { + instanceId, + json = JsonSerializer.Serialize(changes.Select(c => new + { + DetectedAt = Utc(c.DetectedAt).ToString("yyyy-MM-ddTHH:mm:ss", System.Globalization.CultureInfo.InvariantCulture), + Area = Truncate(c.Area, 40), + Name = Truncate(c.Name, 260), + OldValue = c.OldValue is null ? null : Truncate(c.OldValue, 400), + NewValue = c.NewValue is null ? null : Truncate(c.NewValue, 400), + }), Json), + }).ConfigureAwait(false); + + static string Truncate(string value, int length) => value.Length <= length ? value : value[..length]; + } + private static Task SaveWaitsAsync(SqlConnection connection, int instanceId, WaitStatsDelta delta) => connection.ExecuteAsync(""" INSERT insight.WaitSamples (InstanceId, SampledAt, WaitType, IntervalSeconds, WaitingTasks, WaitTimeMs, SignalWaitTimeMs) diff --git a/src/SQLInsight.Repository/Migrations/0008_configuration_changes.sql b/src/SQLInsight.Repository/Migrations/0008_configuration_changes.sql new file mode 100644 index 0000000..7e9444a --- /dev/null +++ b/src/SQLInsight.Repository/Migrations/0008_configuration_changes.sql @@ -0,0 +1,13 @@ +/* Configuration change log: sp_configure, trace flags, service accounts, version, tempdb files … as detected by the + instance-config collector (what changed, from/to, when it was seen). Kept as long as resolved findings. */ +CREATE TABLE insight.ConfigurationChanges +( + ChangeId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ConfigurationChanges PRIMARY KEY, + InstanceId int NOT NULL CONSTRAINT FK_ConfigurationChanges_Instances REFERENCES insight.Instances (InstanceId), + DetectedAt datetime2(0) NOT NULL, + Area nvarchar(40) NOT NULL, + Name nvarchar(260) NOT NULL, + OldValue nvarchar(400) NULL, + NewValue nvarchar(400) NULL +); +CREATE INDEX IX_ConfigurationChanges_Instance ON insight.ConfigurationChanges (InstanceId, DetectedAt); diff --git a/src/SQLInsight.Rules/BuiltInRules.cs b/src/SQLInsight.Rules/BuiltInRules.cs index 05ea4e7..24cb101 100644 --- a/src/SQLInsight.Rules/BuiltInRules.cs +++ b/src/SQLInsight.Rules/BuiltInRules.cs @@ -30,6 +30,7 @@ public static class BuiltInRules .. SimpleConfigurationRules.Create(), new PendingConfigurationRule(), new DeadlockTraceFlagRule(), + new ConfigurationChangedRule(), // Databases new DatabaseStateRule(), diff --git a/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs b/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs index dc7b5e6..030d169 100644 --- a/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs +++ b/src/SQLInsight.Rules/Configuration/ConfigurationRules.cs @@ -400,3 +400,45 @@ public sealed class DeadlockTraceFlagRule : Rule references: Docs.Learn("Deadlocks guide", "relational-databases/sql-server-deadlocks-guide"))); } } + +/// +/// A setting someone changed: sp_configure, a trace flag or a service account. Information, kept open for a day, so the +/// change is visible to whoever looks (and in the change log on the configuration page for good). +/// +public sealed class ConfigurationChangedRule : Rule +{ + public override string Id => "SQLI-CFG-013"; + public override string Title => "Konfiguration ændret"; + + public override RuleEvaluation Evaluate(RuleContext context) + { + ArgumentNullException.ThrowIfNull(context); + var snapshot = context.Data.Get(); + return Result(snapshot.RecentChanges + .Where(c => ConfigurationFingerprint.IsNotable(c.Area)) + .Select(c => + { + var account = c.Area == ConfigurationFingerprint.ServiceAccount; + var what = c.Area == ConfigurationFingerprint.TraceFlag ? $"Trace flag {c.Name}" : c.Name; + var title = (c.OldValue, c.NewValue) switch + { + (null, _) when c.Area == ConfigurationFingerprint.TraceFlag => $"{what} er slået til", + (_, null) when c.Area == ConfigurationFingerprint.TraceFlag => $"{what} er slået fra", + _ when account => $"Tjenestekontoen for {what} er ændret", + _ => $"{what} er ændret fra {c.OldValue ?? "–"} til {c.NewValue ?? "–"}", + }; + return CreateFinding( + Severity.Info, + title, + "En indstilling på instansen er ændret. Det er ikke nødvendigvis et problem, men ændringer af serverindstillinger, " + + "trace flags og tjenestekonti bør være planlagte og kendte, fordi de påvirker hele instansen.", + "Bekræft, at ændringen var planlagt, og at den er dokumenteret. Hele historikken står under Konfiguration på instansens side.", + [ + new Evidence("Område", c.Area), new Evidence("Indstilling", c.Name), + new Evidence("Fra", c.OldValue ?? "–", Sensitive: account), new Evidence("Til", c.NewValue ?? "–", Sensitive: account), + new Evidence("Opdaget", c.DetectedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)), + ], + subject: $"config: {c.Area}/{c.Name}/{c.DetectedAt.UtcDateTime:yyyyMMddHHmm}"); + })); + } +} diff --git a/src/SQLInsight.Web/Components/Pages/Configuration.razor b/src/SQLInsight.Web/Components/Pages/Configuration.razor new file mode 100644 index 0000000..fe1192b --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/Configuration.razor @@ -0,0 +1,192 @@ +@page "/instanser/{Name}/konfiguration" +@inject InsightRepository Repository +@inject Display Display + +@if (_instance is null) +{ + Ukendt instans · SQLInsight Pro +

Ukendt instans

+

Der findes ingen instans med navnet @Name.

+ return; +} + +Konfiguration · @_instance.Name · SQLInsight Pro +

Instanser / @_instance.Name / Konfiguration

+

Konfiguration

+

Hvordan instansen er sat op, og hvad der er ændret. Opdateres hvert 15. minut@(_setup is null ? "" : $", senest {Display.Ago(_setup.CapturedAt.UtcDateTime)}").

+ +@if (_setup is null && _config is null) +{ +

Endnu ingen målinger. Konfigurationen læses første gang inden for 15 minutter efter opgradering til version 1.0.5.

+} + +
+
+

Instans

+ + + + + + + + +
Version@Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@(_setup?.ProductVersion ?? _instance.ProductVersion) @(_setup?.ProductLevel ?? _instance.ProductLevel) @_instance.ProductUpdateLevel)
Edition@(_setup?.Edition ?? _instance.Edition)
Collation@(_setup?.Collation ?? "–")
Standardmappe, data@(_setup?.DefaultDataPath ?? "–")
Standardmappe, log@(_setup?.DefaultLogPath ?? "–")
+
+
+

CPU og hukommelse

+ + + + + + + + + + + + + + +
Logiske CPU'er@(_server?.LogicalCpuCount.ToString() ?? "–")
Schedulers i brug@(_server?.VisibleOnlineSchedulers?.ToString() ?? "–")
NUMA-noder@(_server?.NumaNodeCount?.ToString() ?? "–")
Fysisk hukommelse@(_server?.PhysicalMemoryMb is { } mb ? $"{Display.Number(mb / 1024.0, 1)} GB" : "–")
Virtuel maskine@(_server?.VirtualMachineType ?? "–")
max server memory@Option("max server memory (MB)", mb => mb >= 2147483647 ? "ikke sat (ubegrænset)" : $"{Display.Number(mb / 1024.0, 1)} GB")
min server memory@Option("min server memory (MB)", mb => $"{Display.Number(mb / 1024.0, 1)} GB")
Lock Pages in Memory@(_setup?.MemoryModel switch { "LOCK_PAGES" => "til", "LARGE_PAGES" => "til (large pages)", "CONVENTIONAL" => "fra", _ => "kan ikke aflæses på denne version" })
Instant File Initialization@(_setup?.InstantFileInitialization switch { true => "til", false => "fra", null => "kan ikke aflæses på denne version" })
MAXDOP@Option("max degree of parallelism", v => v.ToString())
Cost threshold for parallelism@Option("cost threshold for parallelism", v => v.ToString())
+
+
+ +
+

Tjenester

+ @if (_setup is not { ServicesAvailable: true }) + { +

Tjenester og deres konti kan kun aflæses fra SQL Server 2008 R2 SP1 (sys.dm_server_services).

+ } + else + { + + + + @foreach (var s in _setup.Services) + { + + } + +
TjenesteKontoStartStatusInstant File Initialization
@s.Name@s.Account@s.StartupType@s.Status@(s.InstantFileInitialization switch { true => "til", false => "fra", null => "–" })
+ } +
+ +
+
+

Tempdb

@(_setup?.TempdbFiles.Count(f => f.Type != "LOG") ?? 0) datafiler
+ @if (_setup is null || _setup.TempdbFiles.Count == 0) + { +

Ingen oplysninger.

+ } + else + { + + + + @foreach (var f in _setup.TempdbFiles) + { + + } + +
FilTypeStørrelseAutogrowthMaks.
@f.Name@f.Type@Display.Number(f.SizeMb) MB@f.Growth@f.MaxSize
+ } +
+
+

Trace flags

+ @if (_traceFlags is null || _traceFlags.GlobalTraceFlags.Count == 0) + { +

Ingen globale trace flags er slået til.

+ } + else + { +

@string.Join(", ", _traceFlags.GlobalTraceFlags)

+ } +
+
+ +
+

Indstillinger, der afviger fra standard

sp_configure
+ @if (_nonDefault.Count == 0) + { +

@(_config is null ? "Ingen oplysninger." : "Alle kendte indstillinger står på SQL Servers standardværdi.")

+ } + else + { + + + + @foreach (var o in _nonDefault) + { + + + + + + + } + +
IndstillingStandardI brugKonfigureret
@o.Name@(ConfigurationFingerprint.DefaultValue(o.Name)?.ToString() ?? "–")@o.ValueInUse@o.Value@(o.Value != o.ValueInUse ? " (afventer RECONFIGURE eller genstart)" : "")
+ } +
+ +
+

Ændringslog

seneste @Dage dage
+ + @if (_changes.Count == 0) + { +

Ingen ændringer registreret i perioden. Den første måling efter installation er udgangspunktet; ændringer derefter vises her.

+ } + else + { + + + + @foreach (var c in _changes) + { + + } + +
OpdagetOmrådeIndstillingFraTil
@Display.Time(c.DetectedAt)@c.Area@c.Name@(c.OldValue ?? "–")@(c.NewValue ?? "–")
+ } +
+ +@code { + [Parameter] public string Name { get; set; } = string.Empty; + [SupplyParameterFromQuery] public int? Dage { get; set; } + + private InstanceOverview? _instance; + private ServerSnapshot? _server; + private ConfigurationSnapshot? _config; + private TraceFlagSnapshot? _traceFlags; + private InstanceConfigurationSnapshot? _setup; + private IReadOnlyList _nonDefault = []; + private IReadOnlyList _changes = []; + + protected override async Task OnParametersSetAsync() + { + Dage = Math.Clamp(Dage ?? 90, 1, 366); + _instance = await Repository.GetInstanceAsync(Name); + if (_instance is null) + { + return; + } + + var id = _instance.InstanceId; + _server = await Repository.GetSnapshotAsync(id, "server"); + _config = await Repository.GetSnapshotAsync(id, "configuration"); + _traceFlags = await Repository.GetSnapshotAsync(id, "trace-flags"); + _setup = await Repository.GetSnapshotAsync(id, "instance-config"); + _nonDefault = (_config?.Options ?? []) + .Where(o => o.Value != o.ValueInUse || ConfigurationFingerprint.DefaultValue(o.Name) is { } d && d != o.ValueInUse) + .OrderBy(o => o.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var now = DateTimeOffset.UtcNow; + _changes = await Repository.GetConfigurationChangesAsync(id, now.AddDays(-Dage.Value), now, 500); + } + + private string Option(string name, Func format) => _config?.Find(name) is { } o ? format(o.ValueInUse) : "–"; +} diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index b76e4ff..cc88beb 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -18,6 +18,9 @@ @_instance.ServerName · @Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@_instance.ProductVersion @_instance.ProductLevel @_instance.ProductUpdateLevel) · @_instance.Edition · sidst set @Display.Ago(_instance.LastSeenAt)

+ @if (_instance.IsFailing) { diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css index 7dc9ab0..2af74b0 100644 --- a/src/SQLInsight.Web/wwwroot/app.css +++ b/src/SQLInsight.Web/wwwroot/app.css @@ -109,6 +109,9 @@ th { background: var(--surface-2); font-size: .74rem; text-transform: uppercase; tbody tr:hover td { background: color-mix(in srgb, var(--surface-2) 60%, transparent); } tr:last-child td { border-bottom: 0; } td .muted { font-size: .82rem; } +/* Key/value tables (configuration): the key column as a row heading. */ +table.kv th { width: 40%; text-transform: none; letter-spacing: 0; font-size: .85rem; } +.nowrap { white-space: nowrap; } .num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } .bad { color: var(--critical); font-weight: 600; } meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; } diff --git a/tests/SQLInsight.Core.Tests/ConfigurationFingerprintTests.cs b/tests/SQLInsight.Core.Tests/ConfigurationFingerprintTests.cs new file mode 100644 index 0000000..ef2839a --- /dev/null +++ b/tests/SQLInsight.Core.Tests/ConfigurationFingerprintTests.cs @@ -0,0 +1,69 @@ +using SQLInsight.Core.Data; + +namespace SQLInsight.Core.Tests; + +public class ConfigurationFingerprintTests +{ + private static readonly DateTimeOffset At = new(2026, 10, 2, 8, 0, 0, TimeSpan.Zero); + + [Fact] + public void Changed_added_and_removed_items_are_listed() + { + ConfigurationItem[] before = + [ + new("sp_configure", "max degree of parallelism", "0"), + new("Trace flag", "1222", "til"), + new("Tjenestekonto", "SQL Server (MSSQLSERVER)", @"NT Service\MSSQLSERVER"), + ]; + ConfigurationItem[] after = + [ + new("sp_configure", "max degree of parallelism", "1"), + new("Trace flag", "3226", "til"), + new("Tjenestekonto", "SQL Server (MSSQLSERVER)", @"NT Service\MSSQLSERVER"), + ]; + + var changes = ConfigurationFingerprint.Diff(before, after, At); + + Assert.Equal(3, changes.Count); + Assert.Contains(new ConfigurationChange("sp_configure", "max degree of parallelism", "0", "1", At), changes); + Assert.Contains(new ConfigurationChange("Trace flag", "3226", null, "til", At), changes); + Assert.Contains(new ConfigurationChange("Trace flag", "1222", "til", null, At), changes); + } + + [Fact] + public void Same_setup_has_no_changes() => + Assert.Empty(ConfigurationFingerprint.Diff([new("Instans", "Collation", "Danish_Norwegian_CI_AS")], [new("Instans", "Collation", "Danish_Norwegian_CI_AS")], At)); + + [Fact] + public void Fingerprint_covers_options_flags_services_and_instance_settings() + { + var instance = new InstanceConfigurationSnapshot + { + CapturedAt = At, + ProductVersion = "16.0.4135.4", + ProductLevel = "RTM", + Collation = "Danish_Norwegian_CI_AS", + Services = [new ServiceInfo("SQL Server (MSSQLSERVER)", @"KUNDE\svc-sql", "Automatic", "Running", true)], + InstantFileInitialization = true, + TempdbFiles = [new TempdbFile("tempdev", "ROWS", 8, "64 MB", "ubegrænset"), new TempdbFile("temp2", "ROWS", 8, "64 MB", "ubegrænset"), new TempdbFile("templog", "LOG", 8, "64 MB", "ubegrænset")], + }; + + var items = ConfigurationFingerprint.Items(instance, [new ConfigurationOption("max server memory (MB)", 8192, 8192)], [1222]); + + Assert.Contains(new ConfigurationItem("sp_configure", "max server memory (MB)", "8192"), items); + Assert.Contains(new ConfigurationItem("Trace flag", "1222", "til"), items); + Assert.Contains(new ConfigurationItem("Tjenestekonto", "SQL Server (MSSQLSERVER)", @"KUNDE\svc-sql"), items); + Assert.Contains(new ConfigurationItem("Instans", "Version", "16.0.4135.4 RTM"), items); + Assert.Contains(new ConfigurationItem("Tempdb", "Antal datafiler", "2"), items); + Assert.Contains(new ConfigurationItem("Hukommelse", "Instant File Initialization", "til"), items); + Assert.DoesNotContain(items, i => i.Name == "Lock Pages in Memory"); // unknown on this build: not part of the fingerprint + } + + [Theory] + [InlineData("cost threshold for parallelism", 5L)] + [InlineData("max server memory (MB)", 2147483647L)] + [InlineData("default trace enabled", 1L)] + [InlineData("an option nobody knows", null)] + public void Defaults_are_known_for_the_documented_options(string option, long? expected) => + Assert.Equal(expected, ConfigurationFingerprint.DefaultValue(option)); +} diff --git a/tests/SQLInsight.IntegrationTests/CollectorTests.cs b/tests/SQLInsight.IntegrationTests/CollectorTests.cs index 2770739..5532aa9 100644 --- a/tests/SQLInsight.IntegrationTests/CollectorTests.cs +++ b/tests/SQLInsight.IntegrationTests/CollectorTests.cs @@ -40,6 +40,32 @@ public class CollectorTests(SqlServerFixture sql) Assert.True(server.SqlServerStartTime < DateTime.Now.AddMinutes(1)); } + [Fact] + public async Task Instance_configuration_reads_the_setup_and_reports_what_changed() + { + await using var session = await AgentSession.OpenAsync(sql); + var collector = new InstanceConfigurationCollector(); + var first = await session.CollectAsync(collector); + await sql.ExecuteAsSaAsync("EXEC sp_configure 'show advanced options', 1; RECONFIGURE; EXEC sp_configure 'cost threshold for parallelism', 7; RECONFIGURE;"); + try + { + var second = await session.CollectAsync(collector, first); + + Assert.Empty(first.Changes); + Assert.False(string.IsNullOrEmpty(first.Collation)); + Assert.NotEmpty(first.TempdbFiles); + Assert.True(first.ServicesAvailable); // sys.dm_server_services with VIEW SERVER STATE + Assert.Contains(first.Items, i => i.Area == ConfigurationFingerprint.SpConfigure && i.Name == "cost threshold for parallelism" && i.Value == "5"); + var change = Assert.Single(second.Changes, c => c.Name == "cost threshold for parallelism"); + Assert.Equal(("5", "7"), (change.OldValue, change.NewValue)); + Assert.Contains(second.RecentChanges, c => c.Name == "cost threshold for parallelism"); + } + finally + { + await sql.ExecuteAsSaAsync("EXEC sp_configure 'cost threshold for parallelism', 5; RECONFIGURE; EXEC sp_configure 'show advanced options', 0; RECONFIGURE;"); + } + } + [Fact] public async Task Configuration_contains_well_known_options() { diff --git a/tests/SQLInsight.IntegrationTests/ConfigurationPageTests.cs b/tests/SQLInsight.IntegrationTests/ConfigurationPageTests.cs new file mode 100644 index 0000000..f874e91 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/ConfigurationPageTests.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using SQLInsight.Core.Data; +using SQLInsight.Core.Instances; +using SQLInsight.Repository; + +namespace SQLInsight.IntegrationTests; + +/// The configuration page and its change log, from stored snapshots. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public class ConfigurationPageTests(SqlServerFixture sql) : IAsyncLifetime +{ + private readonly string _database = "SQLInsightConfig_" + Guid.NewGuid().ToString("N")[..8]; + private InsightRepository _repository = null!; + private int _instanceId; + + public async Task InitializeAsync() + { + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await _repository.MigrateAsync(); + var info = new InstanceInfo + { + ServerName = "AX01", + Version = SqlServerVersion.Parse("16.0.4135.4"), + ProductLevel = "RTM", + Edition = "Standard Edition", + EngineEdition = EngineEdition.Standard, + }; + _instanceId = await _repository.UpsertInstanceAsync(@"AX01\AX30", info, "SqlClient", false, DateTimeOffset.UtcNow); + } + + public async Task DisposeAsync() => + await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + + [Fact] + public async Task Page_shows_the_setup_non_default_options_and_the_change_log() + { + var now = DateTimeOffset.UtcNow; + await _repository.SaveCollectedAsync(_instanceId, "configuration", new ConfigurationSnapshot + { + CapturedAt = now, + Options = [new ConfigurationOption("max degree of parallelism", 1, 1), new ConfigurationOption("cost threshold for parallelism", 5, 5)], + }); + await _repository.SaveCollectedAsync(_instanceId, "instance-config", new InstanceConfigurationSnapshot + { + CapturedAt = now, + Collation = "Danish_Norwegian_CI_AS", + ServicesAvailable = true, + Services = [new ServiceInfo("SQL Server (AX30)", @"KUNDE\svc-ax30", "Automatic", "Running", true)], + InstantFileInitialization = true, + MemoryModel = "LOCK_PAGES", + TempdbFiles = [new TempdbFile("tempdev", "ROWS", 1024, "64 MB", "ubegrænset")], + Changes = [new ConfigurationChange(ConfigurationFingerprint.SpConfigure, "max degree of parallelism", "0", "1", now)], + }); + + var changes = await _repository.GetConfigurationChangesAsync(_instanceId, now.AddDays(-1), now.AddMinutes(1)); + await using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); + b.UseSetting("SQLInsight:Web:Authentication", "None"); + }); + var name = Uri.EscapeDataString(@"AX01\AX30"); + var html = await factory.CreateClient().GetStringAsync($"/instanser/{name}/konfiguration"); + var instancePage = await factory.CreateClient().GetStringAsync($"/instanser/{name}"); + + var change = Assert.Single(changes); + Assert.Equal(("0", "1"), (change.OldValue, change.NewValue)); + Assert.Contains("Danish_Norwegian_CI_AS", html, StringComparison.Ordinal); + Assert.Contains(@"KUNDE\svc-ax30", html, StringComparison.Ordinal); + Assert.Contains("Lock Pages in Memory", html, StringComparison.Ordinal); + Assert.Contains("Ændringslog", html, StringComparison.Ordinal); + Assert.Contains("max degree of parallelism", html, StringComparison.Ordinal); // non-default (1 vs 0) and in the log + Assert.DoesNotContain("cost threshold for parallelism", html, StringComparison.Ordinal); // default: not listed + Assert.Contains($"/instanser/{name}/konfiguration", instancePage, StringComparison.Ordinal); + } +} diff --git a/tests/SQLInsight.Rules.Tests/ConfigurationRuleTests.cs b/tests/SQLInsight.Rules.Tests/ConfigurationRuleTests.cs index e1fd9d3..7ad6d5f 100644 --- a/tests/SQLInsight.Rules.Tests/ConfigurationRuleTests.cs +++ b/tests/SQLInsight.Rules.Tests/ConfigurationRuleTests.cs @@ -157,4 +157,30 @@ public class ConfigurationRuleTests Assert.Equal(expected, Run(new DeadlockTraceFlagRule(), context).Outcome); } + + [Fact] + public void Configuration_changes_are_information_for_a_day() + { + var snapshot = new InstanceConfigurationSnapshot + { + CapturedAt = Now, + RecentChanges = + [ + new ConfigurationChange(ConfigurationFingerprint.SpConfigure, "max degree of parallelism", "0", "1", Now.AddHours(-2)), + new ConfigurationChange(ConfigurationFingerprint.TraceFlag, "1222", null, "til", Now.AddMinutes(-15)), + new ConfigurationChange(ConfigurationFingerprint.ServiceAccount, "SQL Server (MSSQLSERVER)", @"NT Service\MSSQLSERVER", @"KUNDE\svc-sql", Now), + new ConfigurationChange(ConfigurationFingerprint.Instance, "Version", "16.0.4135.4 RTM", "16.0.4175.1 RTM", Now), // an update, not a finding + ], + }; + + var result = Run(new ConfigurationChangedRule(), With(snapshot)); + + Assert.Equal(3, result.Findings.Count); + Assert.All(result.Findings, f => Assert.Equal(Severity.Info, f.Severity)); + Assert.Contains(result.Findings, f => f.Title == "max degree of parallelism er ændret fra 0 til 1"); + Assert.Contains(result.Findings, f => f.Title == "Trace flag 1222 er slået til"); + var account = result.Findings.Single(f => f.Title.StartsWith("Tjenestekontoen", StringComparison.Ordinal)); + Assert.DoesNotContain("svc-sql", account.Title, StringComparison.Ordinal); + Assert.True(account.Evidence.Single(e => e.Name == "Til").Sensitive); + } } diff --git a/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs index f68ef64..ebb6883 100644 --- a/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs +++ b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs @@ -18,6 +18,6 @@ public class RuleCatalogTests [Fact] public void Rule_count() { - Assert.Equal(61, BuiltInRules.Create().Count); + Assert.Equal(62, BuiltInRules.Create().Count); } }