Configuration page with change log
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
ac13e3d826
commit
ab8cfcad97
24 changed files with 888 additions and 12 deletions
|
|
@ -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.
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
21
docs/konfiguration.md
Normal file
21
docs/konfiguration.md
Normal file
|
|
@ -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).
|
||||||
|
|
@ -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.
|
- **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.
|
- **Instanser** med version, CU-niveau og antal fund.
|
||||||
- **Kritiske fund og fund med høj alvor** med alder.
|
- **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.
|
"Ikke målt" betyder, at SQL Server-versionen, rettighederne eller en fejl forhindrede målingen. Det tælles aldrig som opfyldt.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,12 @@ public sealed partial class InstanceMonitor(
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<object?> SeedAsync(ICollector collector, int instanceId, DateTimeOffset now, CancellationToken cancellationToken)
|
private async Task<object?> 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<Core.Data.InstanceConfigurationSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (collector is not Collectors.Concurrency.DeadlockCollector)
|
if (collector is not Collectors.Concurrency.DeadlockCollector)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ public static class BuiltInCollectors
|
||||||
new ServerCollector(),
|
new ServerCollector(),
|
||||||
new ConfigurationCollector(),
|
new ConfigurationCollector(),
|
||||||
new TraceFlagCollector(),
|
new TraceFlagCollector(),
|
||||||
|
new InstanceConfigurationCollector(),
|
||||||
new DatabaseCollector(),
|
new DatabaseCollector(),
|
||||||
new SuspectPageCollector(),
|
new SuspectPageCollector(),
|
||||||
new PlanCacheCollector(),
|
new PlanCacheCollector(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Collectors.Instance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>sp_configure</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InstanceConfigurationCollector : Collector<InstanceConfigurationSnapshot>
|
||||||
|
{
|
||||||
|
// 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<InstanceConfigurationSnapshot> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Data.Common;
|
||||||
using SQLInsight.Core.Data;
|
using SQLInsight.Core.Data;
|
||||||
|
|
||||||
namespace SQLInsight.Collectors.Instance;
|
namespace SQLInsight.Collectors.Instance;
|
||||||
|
|
@ -7,15 +8,19 @@ public sealed class TraceFlagCollector : Collector<TraceFlagSnapshot>
|
||||||
public override string Name => "trace-flags";
|
public override string Name => "trace-flags";
|
||||||
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
|
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
protected override async Task<TraceFlagSnapshot> CollectAsync(CollectorContext context, TraceFlagSnapshot? previous)
|
protected override async Task<TraceFlagSnapshot> CollectAsync(CollectorContext context, TraceFlagSnapshot? previous) =>
|
||||||
|
new(context.Now, await ReadGlobalFlagsAsync(context.Connection, context.CancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
|
/// <summary>The globally enabled trace flags, in order.</summary>
|
||||||
|
internal static async Task<IReadOnlyList<int>> ReadGlobalFlagsAsync(DbConnection connection, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 2005+: TraceFlag, Status, Global, Session. 2000: TraceFlag, Status (only global flags are listed with -1).
|
// 2005+: TraceFlag, Status, Global, Session. 2000: TraceFlag, Status (only global flags are listed with -1).
|
||||||
var rows = await Db.QueryAsync(
|
var rows = await Db.QueryAsync(
|
||||||
context.Connection,
|
connection,
|
||||||
"DBCC TRACESTATUS(-1) WITH NO_INFOMSGS",
|
"DBCC TRACESTATUS(-1) WITH NO_INFOMSGS",
|
||||||
r => (Flag: r.Int(0), Enabled: r.Bool(1), Global: r.FieldCount < 3 || r.Bool(2)),
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
198
src/SQLInsight.Core/Data/InstanceConfiguration.cs
Normal file
198
src/SQLInsight.Core/Data/InstanceConfiguration.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the instance is set up, beyond <c>sp_configure</c>: 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 <c>sp_configure</c> and trace flags), so changes between two collections can be listed.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>SQL Server and SQL Server Agent services (<c>sys.dm_server_services</c>, 2008 R2 SP1+); empty before.</summary>
|
||||||
|
public IReadOnlyList<ServiceInfo> Services { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether the services could be read at all (the DMV exists).</summary>
|
||||||
|
public bool ServicesAvailable { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Instant File Initialization for the database engine service (2012 SP4, 2016 SP1+); null when unknown.</summary>
|
||||||
|
public bool? InstantFileInitialization { get; init; }
|
||||||
|
|
||||||
|
/// <summary>CONVENTIONAL, LOCK_PAGES or LARGE_PAGES (2012 SP4, 2016 SP1+); null when unknown.</summary>
|
||||||
|
public string? MemoryModel { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<TempdbFile> TempdbFiles { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Default data and log folders for new databases (2012+); null when unknown.</summary>
|
||||||
|
public string? DefaultDataPath { get; init; }
|
||||||
|
|
||||||
|
public string? DefaultLogPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The setup as comparable items: what <see cref="Changes"/> is computed from.</summary>
|
||||||
|
public IReadOnlyList<ConfigurationItem> Items { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>What changed since the previous collection (empty on the first).</summary>
|
||||||
|
public IReadOnlyList<ConfigurationChange> Changes { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Changes detected within the last <see cref="ConfigurationFingerprint.RecentWindow"/>, for the finding.</summary>
|
||||||
|
public IReadOnlyList<ConfigurationChange> RecentChanges { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ServiceInfo(string Name, string? Account, string? StartupType, string? Status, bool? InstantFileInitialization);
|
||||||
|
|
||||||
|
/// <param name="Growth">"64 MB" or "10 %"; "ingen" when autogrowth is off.</param>
|
||||||
|
/// <param name="MaxSize">"ubegrænset" or a size.</param>
|
||||||
|
public sealed record TempdbFile(string Name, string Type, long SizeMb, string Growth, string MaxSize);
|
||||||
|
|
||||||
|
/// <param name="Area">sp_configure, Trace flag, Tjenestekonto, Instans, Tempdb, Hukommelse.</param>
|
||||||
|
public sealed record ConfigurationItem(string Area, string Name, string Value);
|
||||||
|
|
||||||
|
/// <param name="OldValue">Null when the item is new.</param>
|
||||||
|
/// <param name="NewValue">Null when the item was removed (a trace flag turned off, a service gone).</param>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>How long a change keeps its "Konfiguration ændret" finding open.</summary>
|
||||||
|
public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24);
|
||||||
|
|
||||||
|
/// <summary>Areas whose changes are findings: settings someone deliberately changed.</summary>
|
||||||
|
public static bool IsNotable(string area) => area is SpConfigure or TraceFlag or ServiceAccount;
|
||||||
|
|
||||||
|
/// <summary>Items present, changed or removed between two fingerprints. Order: area, then name.</summary>
|
||||||
|
public static IReadOnlyList<ConfigurationChange> Diff(IReadOnlyList<ConfigurationItem> before, IReadOnlyList<ConfigurationItem> 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<ConfigurationChange>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The fingerprint of sp_configure, trace flags and the instance-level settings.</summary>
|
||||||
|
public static IReadOnlyList<ConfigurationItem> Items(InstanceConfigurationSnapshot instance, IReadOnlyList<ConfigurationOption> options, IReadOnlyList<int> traceFlags)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(instance);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(traceFlags);
|
||||||
|
var items = new List<ConfigurationItem>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public static long? DefaultValue(string option) => Defaults.TryGetValue(option, out var value) ? value : null;
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, long> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,7 @@ public static class ManagementReport
|
||||||
Controls(section, data);
|
Controls(section, data);
|
||||||
Instances(section, data);
|
Instances(section, data);
|
||||||
Findings(section, data);
|
Findings(section, data);
|
||||||
|
ConfigurationChanges(section, data);
|
||||||
About(section, data);
|
About(section, data);
|
||||||
|
|
||||||
var renderer = new PdfDocumentRenderer { Document = document };
|
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)
|
private static void About(Section section, ReportData data)
|
||||||
{
|
{
|
||||||
section.AddParagraph("Om rapporten", StyleNames.Heading1);
|
section.AddParagraph("Om rapporten", StyleNames.Heading1);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ public sealed record ReportData
|
||||||
public required PeriodStats Period { get; init; }
|
public required PeriodStats Period { get; init; }
|
||||||
public string? RulePack { get; init; }
|
public string? RulePack { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Configuration changes detected in the period (sp_configure, trace flags, service accounts …).</summary>
|
||||||
|
public IReadOnlyList<StoredConfigurationChange> ConfigurationChanges { get; init; } = [];
|
||||||
|
|
||||||
public string Version { get; init; } =
|
public string Version { get; init; } =
|
||||||
typeof(ReportData).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion.Split('+')[0] ?? "0.0.0";
|
typeof(ReportData).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion.Split('+')[0] ?? "0.0.0";
|
||||||
|
|
||||||
|
|
@ -43,6 +46,7 @@ public sealed record ReportData
|
||||||
RuleStates = await repository.GetAllRuleStatesAsync(cancellationToken).ConfigureAwait(false),
|
RuleStates = await repository.GetAllRuleStatesAsync(cancellationToken).ConfigureAwait(false),
|
||||||
Period = await repository.GetPeriodStatsAsync(from, to, cancellationToken).ConfigureAwait(false),
|
Period = await repository.GetPeriodStatsAsync(from, to, cancellationToken).ConfigureAwait(false),
|
||||||
RulePack = (await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false))?.RulePackVersion,
|
RulePack = (await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false))?.RulePackVersion,
|
||||||
|
ConfigurationChanges = await repository.GetConfigurationChangesAsync(null, from, to, 500, cancellationToken).ConfigureAwait(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/SQLInsight.Repository/InsightRepository.Configuration.cs
Normal file
21
src/SQLInsight.Repository/InsightRepository.Configuration.cs
Normal file
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>Configuration changes, newest first: for one instance (or all), detected in [from, to].</summary>
|
||||||
|
public async Task<IReadOnlyList<StoredConfigurationChange>> 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<StoredConfigurationChange>("""
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -89,6 +89,7 @@ public sealed partial class InsightRepository
|
||||||
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
|
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
|
||||||
await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @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.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);
|
await DeleteInBatchesAsync(connection, "insight.OutboundSubmissions", "COALESCE(SentAt, GaveUpAt) < @cutoff", utc.AddDays(-7)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,15 +252,50 @@ public sealed partial class InsightRepository(string connectionString)
|
||||||
case CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>:
|
case CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>:
|
||||||
// Never as a JSON snapshot: it holds statement texts.
|
// Never as a JSON snapshot: it holds statement texts.
|
||||||
return;
|
return;
|
||||||
|
case InstanceConfigurationSnapshot configuration:
|
||||||
|
await SaveConfigurationChangesAsync(connection, instanceId, configuration.Changes).ConfigureAwait(false);
|
||||||
|
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
default:
|
default:
|
||||||
await connection.ExecuteAsync("""
|
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The latest result of a collector, as JSON: the agent reads it back after a restart, the web UI for display.</summary>
|
||||||
|
private static Task<int> SaveSnapshotJsonAsync(SqlConnection connection, int instanceId, string kind, object data) =>
|
||||||
|
connection.ExecuteAsync("""
|
||||||
MERGE insight.Snapshots AS t
|
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
|
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 MATCHED THEN UPDATE SET CapturedAt = SYSUTCDATETIME(), Json = @json
|
||||||
WHEN NOT MATCHED THEN INSERT (InstanceId, Kind, CapturedAt, Json) VALUES (@instanceId, @kind, SYSUTCDATETIME(), @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);
|
""", new { instanceId, kind, json = JsonSerializer.Serialize(data, data.GetType(), Json) });
|
||||||
|
|
||||||
|
private static async Task SaveConfigurationChangesAsync(SqlConnection connection, int instanceId, IReadOnlyList<ConfigurationChange> changes)
|
||||||
|
{
|
||||||
|
if (changes.Count == 0)
|
||||||
|
{
|
||||||
return;
|
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<int> SaveWaitsAsync(SqlConnection connection, int instanceId, WaitStatsDelta delta) =>
|
private static Task<int> SaveWaitsAsync(SqlConnection connection, int instanceId, WaitStatsDelta delta) =>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -30,6 +30,7 @@ public static class BuiltInRules
|
||||||
.. SimpleConfigurationRules.Create(),
|
.. SimpleConfigurationRules.Create(),
|
||||||
new PendingConfigurationRule(),
|
new PendingConfigurationRule(),
|
||||||
new DeadlockTraceFlagRule(),
|
new DeadlockTraceFlagRule(),
|
||||||
|
new ConfigurationChangedRule(),
|
||||||
|
|
||||||
// Databases
|
// Databases
|
||||||
new DatabaseStateRule(),
|
new DatabaseStateRule(),
|
||||||
|
|
|
||||||
|
|
@ -400,3 +400,45 @@ public sealed class DeadlockTraceFlagRule : Rule
|
||||||
references: Docs.Learn("Deadlocks guide", "relational-databases/sql-server-deadlocks-guide")));
|
references: Docs.Learn("Deadlocks guide", "relational-databases/sql-server-deadlocks-guide")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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<InstanceConfigurationSnapshot>();
|
||||||
|
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}");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
192
src/SQLInsight.Web/Components/Pages/Configuration.razor
Normal file
192
src/SQLInsight.Web/Components/Pages/Configuration.razor
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
@page "/instanser/{Name}/konfiguration"
|
||||||
|
@inject InsightRepository Repository
|
||||||
|
@inject Display Display
|
||||||
|
|
||||||
|
@if (_instance is null)
|
||||||
|
{
|
||||||
|
<PageTitle>Ukendt instans · SQLInsight Pro</PageTitle>
|
||||||
|
<h1>Ukendt instans</h1>
|
||||||
|
<p>Der findes ingen instans med navnet <strong>@Name</strong>.</p>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
<PageTitle>Konfiguration · @_instance.Name · SQLInsight Pro</PageTitle>
|
||||||
|
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_instance.Name)">@_instance.Name</a> / Konfiguration</p>
|
||||||
|
<h1>Konfiguration</h1>
|
||||||
|
<p class="lead">Hvordan instansen er sat op, og hvad der er ændret. Opdateres hvert 15. minut@(_setup is null ? "" : $", senest {Display.Ago(_setup.CapturedAt.UtcDateTime)}").</p>
|
||||||
|
|
||||||
|
@if (_setup is null && _config is null)
|
||||||
|
{
|
||||||
|
<p class="muted">Endnu ingen målinger. Konfigurationen læses første gang inden for 15 minutter efter opgradering til version 1.0.5.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Instans</h2></div>
|
||||||
|
<table class="kv">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>Version</th><td>@Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@(_setup?.ProductVersion ?? _instance.ProductVersion) @(_setup?.ProductLevel ?? _instance.ProductLevel) @_instance.ProductUpdateLevel)</td></tr>
|
||||||
|
<tr><th>Edition</th><td>@(_setup?.Edition ?? _instance.Edition)</td></tr>
|
||||||
|
<tr><th>Collation</th><td class="mono">@(_setup?.Collation ?? "–")</td></tr>
|
||||||
|
<tr><th>Standardmappe, data</th><td class="mono">@(_setup?.DefaultDataPath ?? "–")</td></tr>
|
||||||
|
<tr><th>Standardmappe, log</th><td class="mono">@(_setup?.DefaultLogPath ?? "–")</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>CPU og hukommelse</h2></div>
|
||||||
|
<table class="kv">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>Logiske CPU'er</th><td>@(_server?.LogicalCpuCount.ToString() ?? "–")</td></tr>
|
||||||
|
<tr><th>Schedulers i brug</th><td>@(_server?.VisibleOnlineSchedulers?.ToString() ?? "–")</td></tr>
|
||||||
|
<tr><th>NUMA-noder</th><td>@(_server?.NumaNodeCount?.ToString() ?? "–")</td></tr>
|
||||||
|
<tr><th>Fysisk hukommelse</th><td>@(_server?.PhysicalMemoryMb is { } mb ? $"{Display.Number(mb / 1024.0, 1)} GB" : "–")</td></tr>
|
||||||
|
<tr><th>Virtuel maskine</th><td>@(_server?.VirtualMachineType ?? "–")</td></tr>
|
||||||
|
<tr><th>max server memory</th><td>@Option("max server memory (MB)", mb => mb >= 2147483647 ? "ikke sat (ubegrænset)" : $"{Display.Number(mb / 1024.0, 1)} GB")</td></tr>
|
||||||
|
<tr><th>min server memory</th><td>@Option("min server memory (MB)", mb => $"{Display.Number(mb / 1024.0, 1)} GB")</td></tr>
|
||||||
|
<tr><th>Lock Pages in Memory</th><td>@(_setup?.MemoryModel switch { "LOCK_PAGES" => "til", "LARGE_PAGES" => "til (large pages)", "CONVENTIONAL" => "fra", _ => "kan ikke aflæses på denne version" })</td></tr>
|
||||||
|
<tr><th>Instant File Initialization</th><td>@(_setup?.InstantFileInitialization switch { true => "til", false => "fra", null => "kan ikke aflæses på denne version" })</td></tr>
|
||||||
|
<tr><th>MAXDOP</th><td>@Option("max degree of parallelism", v => v.ToString())</td></tr>
|
||||||
|
<tr><th>Cost threshold for parallelism</th><td>@Option("cost threshold for parallelism", v => v.ToString())</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Tjenester</h2></div>
|
||||||
|
@if (_setup is not { ServicesAvailable: true })
|
||||||
|
{
|
||||||
|
<p class="muted">Tjenester og deres konti kan kun aflæses fra SQL Server 2008 R2 SP1 (sys.dm_server_services).</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Tjeneste</th><th>Konto</th><th>Start</th><th>Status</th><th>Instant File Initialization</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var s in _setup.Services)
|
||||||
|
{
|
||||||
|
<tr><td>@s.Name</td><td class="mono">@s.Account</td><td>@s.StartupType</td><td>@s.Status</td><td>@(s.InstantFileInitialization switch { true => "til", false => "fra", null => "–" })</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Tempdb</h2><span class="muted">@(_setup?.TempdbFiles.Count(f => f.Type != "LOG") ?? 0) datafiler</span></div>
|
||||||
|
@if (_setup is null || _setup.TempdbFiles.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">Ingen oplysninger.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Fil</th><th>Type</th><th class="num">Størrelse</th><th>Autogrowth</th><th>Maks.</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var f in _setup.TempdbFiles)
|
||||||
|
{
|
||||||
|
<tr><td>@f.Name</td><td>@f.Type</td><td class="num">@Display.Number(f.SizeMb) MB</td><td>@f.Growth</td><td>@f.MaxSize</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Trace flags</h2></div>
|
||||||
|
@if (_traceFlags is null || _traceFlags.GlobalTraceFlags.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">Ingen globale trace flags er slået til.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="mono">@string.Join(", ", _traceFlags.GlobalTraceFlags)</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Indstillinger, der afviger fra standard</h2><span class="muted">sp_configure</span></div>
|
||||||
|
@if (_nonDefault.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">@(_config is null ? "Ingen oplysninger." : "Alle kendte indstillinger står på SQL Servers standardværdi.")</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Indstilling</th><th class="num">Standard</th><th class="num">I brug</th><th class="num">Konfigureret</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var o in _nonDefault)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@o.Name</td>
|
||||||
|
<td class="num">@(ConfigurationFingerprint.DefaultValue(o.Name)?.ToString() ?? "–")</td>
|
||||||
|
<td class="num">@o.ValueInUse</td>
|
||||||
|
<td class="num @(o.Value != o.ValueInUse ? "bad" : "")">@o.Value@(o.Value != o.ValueInUse ? " (afventer RECONFIGURE eller genstart)" : "")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head"><h2>Ændringslog</h2><span class="muted">seneste @Dage dage</span></div>
|
||||||
|
<p class="report-links">
|
||||||
|
<a href="?dage=7">7 dage</a> <a href="?dage=30">30 dage</a> <a href="?dage=90">90 dage</a> <a href="?dage=365">1 år</a>
|
||||||
|
</p>
|
||||||
|
@if (_changes.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">Ingen ændringer registreret i perioden. Den første måling efter installation er udgangspunktet; ændringer derefter vises her.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Opdaget</th><th>Område</th><th>Indstilling</th><th>Fra</th><th>Til</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _changes)
|
||||||
|
{
|
||||||
|
<tr><td class="nowrap">@Display.Time(c.DetectedAt)</td><td>@c.Area</td><td>@c.Name</td><td class="mono">@(c.OldValue ?? "–")</td><td class="mono">@(c.NewValue ?? "–")</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@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<ConfigurationOption> _nonDefault = [];
|
||||||
|
private IReadOnlyList<StoredConfigurationChange> _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<ServerSnapshot>(id, "server");
|
||||||
|
_config = await Repository.GetSnapshotAsync<ConfigurationSnapshot>(id, "configuration");
|
||||||
|
_traceFlags = await Repository.GetSnapshotAsync<TraceFlagSnapshot>(id, "trace-flags");
|
||||||
|
_setup = await Repository.GetSnapshotAsync<InstanceConfigurationSnapshot>(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<long, string> format) => _config?.Find(name) is { } o ? format(o.ValueInUse) : "–";
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,9 @@
|
||||||
@_instance.ServerName · @Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@_instance.ProductVersion @_instance.ProductLevel @_instance.ProductUpdateLevel) · @_instance.Edition
|
@_instance.ServerName · @Display.Release(_instance.ProductVersion, _instance.EngineEdition) (@_instance.ProductVersion @_instance.ProductLevel @_instance.ProductUpdateLevel) · @_instance.Edition
|
||||||
· sidst set @Display.Ago(_instance.LastSeenAt)
|
· sidst set @Display.Ago(_instance.LastSeenAt)
|
||||||
</p>
|
</p>
|
||||||
|
<p class="report-links">
|
||||||
|
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/konfiguration">Konfiguration</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
@if (_instance.IsFailing)
|
@if (_instance.IsFailing)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
tbody tr:hover td { background: color-mix(in srgb, var(--surface-2) 60%, transparent); }
|
||||||
tr:last-child td { border-bottom: 0; }
|
tr:last-child td { border-bottom: 0; }
|
||||||
td .muted { font-size: .82rem; }
|
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; }
|
.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
.bad { color: var(--critical); font-weight: 600; }
|
.bad { color: var(--critical); font-weight: 600; }
|
||||||
meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; }
|
meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; }
|
||||||
|
|
|
||||||
69
tests/SQLInsight.Core.Tests/ConfigurationFingerprintTests.cs
Normal file
69
tests/SQLInsight.Core.Tests/ConfigurationFingerprintTests.cs
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,32 @@ public class CollectorTests(SqlServerFixture sql)
|
||||||
Assert.True(server.SqlServerStartTime < DateTime.Now.AddMinutes(1));
|
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<InstanceConfigurationSnapshot>(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]
|
[Fact]
|
||||||
public async Task Configuration_contains_well_known_options()
|
public async Task Configuration_contains_well_known_options()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
79
tests/SQLInsight.IntegrationTests/ConfigurationPageTests.cs
Normal file
79
tests/SQLInsight.IntegrationTests/ConfigurationPageTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>The configuration page and its change log, from stored snapshots.</summary>
|
||||||
|
[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<Program>().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</td>", html, StringComparison.Ordinal); // default: not listed
|
||||||
|
Assert.Contains($"/instanser/{name}/konfiguration", instancePage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -157,4 +157,30 @@ public class ConfigurationRuleTests
|
||||||
|
|
||||||
Assert.Equal(expected, Run(new DeadlockTraceFlagRule(), context).Outcome);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,6 @@ public class RuleCatalogTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Rule_count()
|
public void Rule_count()
|
||||||
{
|
{
|
||||||
Assert.Equal(61, BuiltInRules.Create().Count);
|
Assert.Equal(62, BuiltInRules.Create().Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue