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:
parent
384e1f154f
commit
cc71fb12c6
11 changed files with 311 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,10 @@
|
|||
<span>SQLInsight <strong>Pro</strong></span>
|
||||
</a>
|
||||
|
||||
<nav class="nav">
|
||||
<span class="nav-label">Overvågning</span>
|
||||
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink>
|
||||
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
|
||||
<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>
|
||||
@* On narrow screens the menu folds away behind "Menu" (a checkbox: no script). *@
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle" />
|
||||
<label for="nav-toggle" class="nav-toggle-label"><Icon Name="list" /> Menu</label>
|
||||
<MainNav />
|
||||
|
||||
<div class="support-card">
|
||||
<Icon Name="support" />
|
||||
|
|
|
|||
92
src/SQLInsight.Web/Components/Layout/MainNav.razor
Normal file
92
src/SQLInsight.Web/Components/Layout/MainNav.razor
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@
|
|||
</section>
|
||||
|
||||
<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)
|
||||
{
|
||||
<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>
|
||||
|
|
|
|||
69
src/SQLInsight.Web/Components/Pages/ChooseInstance.razor
Normal file
69
src/SQLInsight.Web/Components/Pages/ChooseInstance.razor
Normal 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;
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
</div>
|
||||
|
||||
<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)
|
||||
{
|
||||
<p class="muted">Endnu ikke nok målinger (ventetider kræver to målinger).</p>
|
||||
|
|
@ -88,7 +88,7 @@ else
|
|||
</section>
|
||||
|
||||
<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)." />
|
||||
@if (_diskSeries.Count > 0)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
</section>
|
||||
|
||||
<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 class="report-links">
|
||||
<a class="button" href="/rapporter/rettigheder.pdf?dage=30">Seneste 30 dage</a>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,21 @@
|
|||
case "gauge":
|
||||
<path d="M12 14 16 10" /><path d="M3.5 18a9.5 9.5 0 1 1 17 0" />
|
||||
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:
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -204,3 +204,25 @@ details.not-applicable > table { margin-top: .75rem; }
|
|||
.assess.act { --c: var(--critical); --c-soft: var(--critical-soft); }
|
||||
.assessment { margin: .4rem 0 0; font-size: .85rem; }
|
||||
.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; }
|
||||
}
|
||||
|
|
|
|||
90
tests/SQLInsight.IntegrationTests/NavigationTests.cs
Normal file
90
tests/SQLInsight.IntegrationTests/NavigationTests.cs
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue