diff --git a/docs/installation.md b/docs/installation.md index 4264f46..930a616 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -179,3 +179,7 @@ Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux ## Opdatering Se [opdatering.md](opdatering.md): regelpakken opdateres automatisk, og nye versioner kan installeres med `SQLInsight.Agent update apply` eller automatisk af en planlagt opgave. Manuelt: stop tjenesten (eller app poolen), kopiér de nye filer ind uden at overskrive `appsettings.json`, og start igen. Agenten opgraderer repository-skemaet selv. + +## Menuen i webbrugerfladen + +Menuen er delt i grupper, der kan foldes ud og sammen: **Overvågning**, **Analyse**, **Konfiguration og sikkerhed**, **Rapporter**, **Dynamics AX** (kun når en database er genkendt som AX), **Support**, **System** og **Community**. Gruppen med den side, man står på, er åben. Sider, der findes pr. instans (fx Analyse af tidsrum og Konfiguration), åbner et valg af instans; er der kun én instans, går de direkte til den. På en smal skærm (mobil) folder hele menuen sig sammen bag knappen **Menu**. Alt virker uden JavaScript. diff --git a/src/SQLInsight.Repository/InsightRepository.Read.cs b/src/SQLInsight.Repository/InsightRepository.Read.cs index 44abb5e..9e780ac 100644 --- a/src/SQLInsight.Repository/InsightRepository.Read.cs +++ b/src/SQLInsight.Repository/InsightRepository.Read.cs @@ -271,3 +271,14 @@ public sealed partial class InsightRepository return rows.Select(r => new HourlyValue(r.Metric, DateTime.SpecifyKind(r.Hour, DateTimeKind.Utc), r.Value)).ToList(); } } + +public sealed partial class InsightRepository +{ + /// Whether any instance has a database recognised as Dynamics AX (the AX menu is shown only then). + public async Task HasDynamicsAxDatabasesAsync(CancellationToken cancellationToken = default) + { + await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false); + return await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM insight.Snapshots WHERE Kind = N'dynamics-ax' AND Json LIKE N'%\"database\"%';").ConfigureAwait(false) > 0; + } +} diff --git a/src/SQLInsight.Web/Components/Layout/MainLayout.razor b/src/SQLInsight.Web/Components/Layout/MainLayout.razor index bdbbb0e..abdde83 100644 --- a/src/SQLInsight.Web/Components/Layout/MainLayout.razor +++ b/src/SQLInsight.Web/Components/Layout/MainLayout.razor @@ -9,19 +9,10 @@ SQLInsight Pro - + @* On narrow screens the menu folds away behind "Menu" (a checkbox: no script). *@ + + +
diff --git a/src/SQLInsight.Web/Components/Layout/MainNav.razor b/src/SQLInsight.Web/Components/Layout/MainNav.razor new file mode 100644 index 0000000..b4b3e64 --- /dev/null +++ b/src/SQLInsight.Web/Components/Layout/MainNav.razor @@ -0,0 +1,92 @@ +@inject NavigationManager Navigation +@inject Microsoft.Extensions.Options.IOptions Options +@inject InsightRepository Repository + +@* The menu in foldable groups (details/summary: no script needed). The group with the current page is open. *@ + + +@code { + public sealed record NavItem(string Title, string Href, params string[] Also); + + public sealed record NavGroup(string Key, string Title, string Icon, IReadOnlyList Items); + + /// The menu. Later pages go into the group they belong to. + public static readonly IReadOnlyList Groups = + [ + new("overvaagning", "Overvågning", "servers", + [ + new("Overblik", "/"), + new("Alle fund", "/fund", "/fund/"), + ]), + new("analyse", "Analyse", "chart", + [ + new("Analyse af tidsrum", "/vaelg/analyse", "/analyse", "/sql"), + new("Top-SQL", "/vaelg/topsql"), + new("Ventetyper", "/vaelg/ventetyper", "/ventetyper/"), + new("Diskhastighed", "/vaelg/disk"), + ]), + new("sikkerhed", "Konfiguration og sikkerhed", "shield", + [ + new("Konfiguration", "/vaelg/konfiguration", "/konfiguration"), + new("Rettigheder", "/rettigheder", "/rettigheder"), + ]), + new("rapporter", "Rapporter", "book", + [ + new("Rapporter", "/rapporter"), + new("Revisionsrapport", "/rapporter#rettighedsrevision"), + ]), + new("ax", "Dynamics AX", "cube", []), + ]; + + private string _path = "/"; + private bool _dynamicsAx; + + protected override async Task OnInitializedAsync() + { + _path = "/" + Navigation.ToBaseRelativePath(Navigation.Uri).Split('?', '#')[0]; + _dynamicsAx = Groups.Single(g => g.Key == "ax").Items.Count > 0 && await Repository.HasDynamicsAxDatabasesAsync(); + } + + /// The group holding the current page; Overvågning on the front page and the instance pages. + private bool IsOpen(NavGroup group) => + group.Items.Any(i => Matches(i)) || group.Key == "overvaagning" && !Groups.Any(g => g.Key != "overvaagning" && g.Items.Any(Matches)); + + private bool Matches(NavItem item) + { + var href = item.Href.Split('?', '#')[0]; + if (href == "/" ? _path == "/" : _path.StartsWith(href, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Pages under /instanser/{name}/… belong to the group of the menu entry for that kind of page. + return item.Also.Any(a => a.StartsWith('/') && _path.StartsWith("/instanser/", StringComparison.OrdinalIgnoreCase) + ? _path.Contains(a, StringComparison.OrdinalIgnoreCase) + : _path.StartsWith(a, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/SQLInsight.Web/Components/Pages/Analysis.razor b/src/SQLInsight.Web/Components/Pages/Analysis.razor index 4a2df2d..f57fe85 100644 --- a/src/SQLInsight.Web/Components/Pages/Analysis.razor +++ b/src/SQLInsight.Web/Components/Pages/Analysis.razor @@ -91,7 +91,7 @@
-

Tungeste forespørgsler

summeret over tidsrummet; teksten bliver her og sendes aldrig til SQLInsight Hub
+

Tungeste forespørgsler

summeret over tidsrummet; teksten bliver her og sendes aldrig til SQLInsight Hub
@if (_analysis.TopQueries.Count == 0) {

Ingen forespørgselsstatistik i tidsrummet. Den indsamles hvert 5. minut fra version 1.0.3 og gemmes i @_analysis.RawDays dage.

diff --git a/src/SQLInsight.Web/Components/Pages/ChooseInstance.razor b/src/SQLInsight.Web/Components/Pages/ChooseInstance.razor new file mode 100644 index 0000000..09d075f --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/ChooseInstance.razor @@ -0,0 +1,69 @@ +@page "/vaelg/{Side}" +@inject InsightRepository Repository +@inject NavigationManager Navigation + +@* Menu entries for pages that exist per instance lead here: pick the instance (or go straight there with only one). *@ +@if (_target is null) +{ + Ukendt side · SQLInsight Pro +

Ukendt side

+

Siden @Side findes ikke.

+ return; +} + +@_target.Value.Title · SQLInsight Pro +

@_target.Value.Title

+

Vælg instans.

+ +@if (_instances.Count == 0) +{ +

Der er endnu ingen instanser. Tjek, at SQLInsight Agent kører og har instanser under SQLInsight:Instances.

+} +else +{ +
+ + + + @foreach (var i in _instances.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)) + { + + } + +
InstansServer
@i.Name@i.ServerName
+
+} + +@code { + /// Menu entry → title and the page under /instanser/{name}. + public static readonly IReadOnlyDictionary Targets = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["analyse"] = ("Analyse af tidsrum", "/analyse"), + ["topsql"] = ("Top-SQL", "/analyse?sortering=cpu#topsql"), + ["ventetyper"] = ("Ventetyper", "#ventetider"), + ["disk"] = ("Diskhastighed", "#disk"), + ["konfiguration"] = ("Konfiguration", "/konfiguration"), + }; + + [Parameter] public string Side { get; set; } = string.Empty; + + private (string Title, string Path)? _target; + private IReadOnlyList _instances = []; + + protected override async Task OnParametersSetAsync() + { + _target = Targets.TryGetValue(Side, out var target) ? target : null; + if (_target is null) + { + return; + } + + _instances = await Repository.GetInstancesAsync(); + if (_instances.Count == 1) + { + Navigation.NavigateTo(Url(_instances[0].Name)); + } + } + + private string Url(string instance) => "/instanser/" + Uri.EscapeDataString(instance) + _target!.Value.Path; +} diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index d811fe5..29b4104 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -60,7 +60,7 @@
-

Største ventetider, seneste 24 timer

uden harmløse baggrundsventetider
+

Største ventetider, seneste 24 timer

uden harmløse baggrundsventetider
@if (_waits.Count == 0) {

Endnu ikke nok målinger (ventetider kræver to målinger).

@@ -88,7 +88,7 @@ else
-

Diskhastighed pr. drev, seneste 24 timer

+

Diskhastighed pr. drev, seneste 24 timer

@if (_diskSeries.Count > 0) { diff --git a/src/SQLInsight.Web/Components/Pages/Reports.razor b/src/SQLInsight.Web/Components/Pages/Reports.razor index 2e75c62..1578b53 100644 --- a/src/SQLInsight.Web/Components/Pages/Reports.razor +++ b/src/SQLInsight.Web/Components/Pages/Reports.razor @@ -18,7 +18,7 @@
-

Rettighedsrevision

+

Rettighedsrevision

Hvem har sysadmin og de øvrige forhøjede serverrettigheder nu (Windows-grupper foldet ud til personer), hvem der har fået eller mistet dem i perioden og af hvem, sa-kontoen, SQL-logins uden adgangskodepolitik og default trace. Indeholder navne: rapporten dannes her og sendes ikke nogen steder hen.

The menu in foldable groups, and the instance chooser behind per-instance menu entries. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public partial class NavigationTests(SqlServerFixture sql) : IAsyncLifetime +{ + private readonly string _database = "SQLInsightNav_" + Guid.NewGuid().ToString("N")[..8]; + private WebApplicationFactory _factory = null!; + private InsightRepository _repository = null!; + + public async Task InitializeAsync() + { + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await _repository.MigrateAsync(); + _factory = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); + b.UseSetting("SQLInsight:Web:Authentication", "None"); + }); + } + + public async Task DisposeAsync() + { + await _factory.DisposeAsync(); + await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + } + + private static InstanceInfo Info => new() + { + ServerName = "AX01", + Version = SqlServerVersion.Parse("16.0.4135.4"), + ProductLevel = "RTM", + Edition = "Standard Edition", + EngineEdition = EngineEdition.Standard, + }; + + /// The open groups' keys. + private static List OpenGroups(string html) => + OpenGroup().Matches(html).Select(m => m.Groups[1].Value).ToList(); + + [Fact] + public async Task Menu_has_the_groups_and_opens_the_one_with_the_current_page() + { + var client = _factory.CreateClient(); + + var home = await client.GetStringAsync("/"); + var rights = await client.GetStringAsync("/rettigheder"); + var reports = await client.GetStringAsync("/rapporter"); + + foreach (var title in new[] { "Overvågning", "Analyse", "Konfiguration og sikkerhed", "Rapporter", "Community" }) + { + Assert.Contains($"{title}", home, StringComparison.Ordinal); + } + + Assert.Contains("
", home, StringComparison.Ordinal); // no AX databases + Assert.Contains("nav-toggle", home, StringComparison.Ordinal); // folds away on narrow screens + } + + [Fact] + public async Task Per_instance_entries_choose_the_instance_and_pages_below_an_instance_open_their_group() + { + await _repository.UpsertInstanceAsync(@"AX01\AX30", Info, "SqlClient", false, DateTimeOffset.UtcNow); + await _repository.UpsertInstanceAsync(@"AX02\AX40", Info, "SqlClient", false, DateTimeOffset.UtcNow); + var client = _factory.CreateClient(); + + var chooser = await client.GetStringAsync("/vaelg/konfiguration"); + var configuration = await client.GetStringAsync($"/instanser/{Uri.EscapeDataString(@"AX01\AX30")}/konfiguration"); + var waitType = await client.GetStringAsync($"/instanser/{Uri.EscapeDataString(@"AX01\AX30")}/ventetyper/LCK_M_X"); + + Assert.Contains($"/instanser/{Uri.EscapeDataString(@"AX02\AX40")}/konfiguration", chooser, StringComparison.Ordinal); + Assert.Equal(["sikkerhed"], OpenGroups(configuration)); + Assert.Equal(["analyse"], OpenGroups(waitType)); + } + + [GeneratedRegex("
]*data-nav-group=\"([a-z]+)\"")] + private static partial Regex OpenGroup(); +}