Menu in foldable groups

The web menu is grouped with details/summary (no script, CSP safe):
Overvågning, Analyse, Konfiguration og sikkerhed, Rapporter, Dynamics AX
(hidden until a database is recognised as AX), Support, System and
Community. The group holding the current page is open, including pages
under /instanser/{navn}/. Per-instance entries go through /vaelg/{side}
(straight to the instance when there is only one). On narrow screens
the whole menu folds behind a "Menu" checkbox toggle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 09:17:43 +02:00
parent 384e1f154f
commit cc71fb12c6
11 changed files with 311 additions and 17 deletions

View file

@ -179,3 +179,7 @@ Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux
## Opdatering ## 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. 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.

View file

@ -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(); return rows.Select(r => new HourlyValue(r.Metric, DateTime.SpecifyKind(r.Hour, DateTimeKind.Utc), r.Value)).ToList();
} }
} }
public sealed partial class InsightRepository
{
/// <summary>Whether any instance has a database recognised as Dynamics AX (the AX menu is shown only then).</summary>
public async Task<bool> HasDynamicsAxDatabasesAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM insight.Snapshots WHERE Kind = N'dynamics-ax' AND Json LIKE N'%\"database\"%';").ConfigureAwait(false) > 0;
}
}

View file

@ -9,19 +9,10 @@
<span>SQLInsight <strong>Pro</strong></span> <span>SQLInsight <strong>Pro</strong></span>
</a> </a>
<nav class="nav"> @* On narrow screens the menu folds away behind "Menu" (a checkbox: no script). *@
<span class="nav-label">Overvågning</span> <input type="checkbox" id="nav-toggle" class="nav-toggle" />
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink> <label for="nav-toggle" class="nav-toggle-label"><Icon Name="list" /> Menu</label>
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink> <MainNav />
<NavLink href="/rettigheder"><Icon Name="users" /> Rettigheder</NavLink>
<NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink>
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
<NavLink href="/support"><Icon Name="support" /> Support</NavLink>
<span class="nav-label">Community</span>
<a href="@Options.Value.ForumUrl" rel="noopener"><Icon Name="users" /> Forum</a>
<a href="@(new Uri(Options.Value.CommunityUrl, "dokumentation"))" rel="noopener"><Icon Name="book" /> Dokumentation</a>
</nav>
<div class="support-card"> <div class="support-card">
<Icon Name="support" /> <Icon Name="support" />

View file

@ -0,0 +1,92 @@
@inject NavigationManager Navigation
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
@inject InsightRepository Repository
@* The menu in foldable groups (details/summary: no script needed). The group with the current page is open. *@
<nav class="nav" aria-label="Hovedmenu">
@foreach (var group in Groups)
{
if (group.Key == "ax" && !_dynamicsAx)
{
continue;
}
<details class="nav-group" open="@(IsOpen(group))" data-nav-group="@group.Key">
<summary><Icon Name="@group.Icon" /> <span>@group.Title</span> <Icon Name="chevron" /></summary>
@foreach (var item in group.Items)
{
<NavLink href="@item.Href" Match="@(item.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@item.Title</NavLink>
}
</details>
}
<NavLink href="/support" class="nav-single"><Icon Name="support" /> Support</NavLink>
<NavLink href="/system" class="nav-single"><Icon Name="gauge" /> System</NavLink>
<details class="nav-group" data-nav-group="community">
<summary><Icon Name="users" /> <span>Community</span> <Icon Name="chevron" /></summary>
<a href="@Options.Value.ForumUrl" rel="noopener">Forum</a>
<a href="@(new Uri(Options.Value.CommunityUrl, "dokumentation"))" rel="noopener">Dokumentation</a>
</details>
</nav>
@code {
public sealed record NavItem(string Title, string Href, params string[] Also);
public sealed record NavGroup(string Key, string Title, string Icon, IReadOnlyList<NavItem> Items);
/// <summary>The menu. Later pages go into the group they belong to.</summary>
public static readonly IReadOnlyList<NavGroup> 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();
}
/// <summary>The group holding the current page; Overvågning on the front page and the instance pages.</summary>
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));
}
}

View file

@ -91,7 +91,7 @@
</section> </section>
<section class="card"> <section class="card">
<div class="card-head"><h2>Tungeste forespørgsler</h2><span class="muted">summeret over tidsrummet; teksten bliver her og sendes aldrig til SQLInsight Hub</span></div> <div class="card-head" id="topsql"><h2>Tungeste forespørgsler</h2><span class="muted">summeret over tidsrummet; teksten bliver her og sendes aldrig til SQLInsight Hub</span></div>
@if (_analysis.TopQueries.Count == 0) @if (_analysis.TopQueries.Count == 0)
{ {
<p class="muted">Ingen forespørgselsstatistik i tidsrummet. Den indsamles hvert 5. minut fra version 1.0.3 og gemmes i @_analysis.RawDays dage.</p> <p class="muted">Ingen forespørgselsstatistik i tidsrummet. Den indsamles hvert 5. minut fra version 1.0.3 og gemmes i @_analysis.RawDays dage.</p>

View file

@ -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)
{
<PageTitle>Ukendt side · SQLInsight Pro</PageTitle>
<h1>Ukendt side</h1>
<p>Siden <strong>@Side</strong> findes ikke.</p>
return;
}
<PageTitle>@_target.Value.Title · SQLInsight Pro</PageTitle>
<h1>@_target.Value.Title</h1>
<p class="lead">Vælg instans.</p>
@if (_instances.Count == 0)
{
<p class="muted">Der er endnu ingen instanser. Tjek, at SQLInsight Agent kører og har instanser under <code>SQLInsight:Instances</code>.</p>
}
else
{
<section class="card">
<table>
<thead><tr><th>Instans</th><th>Server</th></tr></thead>
<tbody>
@foreach (var i in _instances.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase))
{
<tr><td><a href="@Url(i.Name)"><strong>@i.Name</strong></a></td><td class="muted">@i.ServerName</td></tr>
}
</tbody>
</table>
</section>
}
@code {
/// <summary>Menu entry → title and the page under /instanser/{name}.</summary>
public static readonly IReadOnlyDictionary<string, (string Title, string Path)> Targets = new Dictionary<string, (string, string)>(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<InstanceOverview> _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;
}

View file

@ -60,7 +60,7 @@
</div> </div>
<section class="card section"> <section class="card section">
<div class="card-head"><h2>Største ventetider, seneste 24 timer</h2><span class="muted">uden harmløse baggrundsventetider</span></div> <div class="card-head" id="ventetider"><h2>Største ventetider, seneste 24 timer</h2><span class="muted">uden harmløse baggrundsventetider</span></div>
@if (_waits.Count == 0) @if (_waits.Count == 0)
{ {
<p class="muted">Endnu ikke nok målinger (ventetider kræver to målinger).</p> <p class="muted">Endnu ikke nok målinger (ventetider kræver to målinger).</p>
@ -88,7 +88,7 @@ else
</section> </section>
<section class="card"> <section class="card">
<div class="card-head"><h2>Diskhastighed pr. drev, seneste 24 timer</h2></div> <div class="card-head" id="disk"><h2>Diskhastighed pr. drev, seneste 24 timer</h2></div>
<DiskTable Disks="_disks" InstanceName="@_instance.Name" Empty="Endnu ingen målinger pr. drev (kræver to målinger efter opgradering til 1.0.5)." /> <DiskTable Disks="_disks" InstanceName="@_instance.Name" Empty="Endnu ingen målinger pr. drev (kræver to målinger efter opgradering til 1.0.5)." />
@if (_diskSeries.Count > 0) @if (_diskSeries.Count > 0)
{ {

View file

@ -18,7 +18,7 @@
</section> </section>
<section class="panel"> <section class="panel">
<h2>Rettighedsrevision</h2> <h2 id="rettighedsrevision">Rettighedsrevision</h2>
<p>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.</p> <p>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.</p>
<p class="report-links"> <p class="report-links">
<a class="button" href="/rapporter/rettigheder.pdf?dage=30">Seneste 30 dage</a> <a class="button" href="/rapporter/rettigheder.pdf?dage=30">Seneste 30 dage</a>

View file

@ -29,6 +29,21 @@
case "gauge": case "gauge":
<path d="M12 14 16 10" /><path d="M3.5 18a9.5 9.5 0 1 1 17 0" /> <path d="M12 14 16 10" /><path d="M3.5 18a9.5 9.5 0 1 1 17 0" />
break; break;
case "chart":
<path d="M3 3v18h18" /><path d="m7 15 4-4 3 3 5-6" />
break;
case "shield":
<path d="M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6l-8-3Z" />
break;
case "home":
<path d="m3 11 9-8 9 8" /><path d="M5 10v10h14V10" />
break;
case "cube":
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z" /><path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
break;
case "chevron":
<path d="m9 6 6 6-6 6" />
break;
default: default:
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
break; break;

View file

@ -204,3 +204,25 @@ details.not-applicable > table { margin-top: .75rem; }
.assess.act { --c: var(--critical); --c-soft: var(--critical-soft); } .assess.act { --c: var(--critical); --c-soft: var(--critical-soft); }
.assessment { margin: .4rem 0 0; font-size: .85rem; } .assessment { margin: .4rem 0 0; font-size: .85rem; }
.assessment details { border-top: 0; padding: .3rem 0 0; } .assessment details { border-top: 0; padding: .3rem 0 0; }
/* Menu groups: details/summary, open for the group with the current page. */
.nav-group { border: 0; padding: 0; margin: 0; }
.nav-group > summary { list-style: none; display: flex; align-items: center; gap: .65rem; padding: .5rem .75rem; border-radius: 8px; color: var(--text-2); font-weight: 600; cursor: pointer; user-select: none; }
.nav-group > summary::-webkit-details-marker { display: none; }
.nav-group > summary:hover { background: var(--surface-2); color: var(--text); }
.nav-group > summary:focus-visible, .nav a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.nav-group > summary > span { flex: 1; }
.nav-group > summary > .icon:last-child { width: 14px; height: 14px; transition: transform .15s ease; color: var(--text-3); }
.nav-group[open] > summary > .icon:last-child { transform: rotate(90deg); }
.nav-group > a { padding-left: 2.6rem; font-weight: 400; }
.nav .nav-single { font-weight: 600; }
@media (max-width: 900px) {
.nav-group > a { padding-top: .6rem; padding-bottom: .6rem; }
}
.nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
.nav-toggle-label { display: none; }
@media (max-width: 900px) {
.nav-toggle-label { display: flex; align-items: center; gap: .5rem; padding: .5rem .75rem; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; font-weight: 600; color: var(--text-2); }
.nav-toggle:focus-visible + .nav-toggle-label { outline: 2px solid var(--accent); }
.nav-toggle:not(:checked) ~ .nav, .nav-toggle:not(:checked) ~ .support-card { display: none; }
}

View file

@ -0,0 +1,90 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>The menu in foldable groups, and the instance chooser behind per-instance menu entries.</summary>
[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<Program> _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<Program>().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,
};
/// <summary>The open groups' keys.</summary>
private static List<string> 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($"<span>{title}</span>", home, StringComparison.Ordinal);
}
Assert.Contains("<details class=\"nav-group\"", home, StringComparison.Ordinal);
Assert.Equal(["overvaagning"], OpenGroups(home));
Assert.Equal(["sikkerhed"], OpenGroups(rights));
Assert.Equal(["rapporter"], OpenGroups(reports));
Assert.DoesNotContain("Dynamics AX</span>", 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("<details class=\"nav-group\" open[^>]*data-nav-group=\"([a-z]+)\"")]
private static partial Regex OpenGroup();
}