docs+ui: backfill XML doc comments and finish dashboard layout pass
Adds missing <summary>/<param> XML docs across 99 server, worker, and test files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the analyzer clean). Bundles in WIP dashboard work: NavSection extraction, MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
<body class="dashboard-body">
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/js/nav-state.js"></script>
|
||||
<script src="/_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,83 +1,210 @@
|
||||
@using System.Linq
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.JSInterop
|
||||
@implements IDisposable
|
||||
@inherits LayoutComponentBase
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<header class="app-bar">
|
||||
<a class="brand" href="/"><span class="mark">▮</span> MXAccess Gateway</a>
|
||||
<span class="crumb">›</span>
|
||||
<span class="crumb">gateway dashboard</span>
|
||||
<span class="spacer"></span>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<span class="meta">@authState.User.Identity?.Name</span>
|
||||
<span class="conn-pill" data-state="connected">
|
||||
<span class="dot"></span><span>signed in</span>
|
||||
</span>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<span class="conn-pill" data-state="disconnected">
|
||||
<span class="dot"></span><span>signed out</span>
|
||||
</span>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</header>
|
||||
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
|
||||
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS
|
||||
lives in bootstrap.bundle.min.js (loaded in App.razor). *@
|
||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#sidebar-collapse"
|
||||
aria-controls="sidebar-collapse"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
☰
|
||||
</button>
|
||||
|
||||
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS lives
|
||||
in bootstrap.bundle.min.js (loaded in App.razor). The app-bar above stays
|
||||
full-width; the hamburger only collapses the side rail. *@
|
||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#sidebar-collapse"
|
||||
aria-controls="sidebar-collapse"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div class="app-shell">
|
||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
||||
<nav class="side-rail">
|
||||
<div class="rail-eyebrow">Overview</div>
|
||||
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<a class="brand" href="/"><span class="mark">▮</span> MXAccess Gateway</a>
|
||||
|
||||
<div class="rail-eyebrow">Runtime</div>
|
||||
<NavLink class="rail-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
|
||||
<NavLink class="rail-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
|
||||
<NavLink class="rail-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
|
||||
<NavLink class="rail-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
|
||||
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||
</li>
|
||||
|
||||
<div class="rail-eyebrow">Galaxy</div>
|
||||
<NavLink class="rail-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
|
||||
<NavLink class="rail-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
|
||||
<NavSection Title="Runtime"
|
||||
Expanded="@_expanded.Contains("runtime")"
|
||||
OnToggle="@(() => ToggleAsync("runtime"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
|
||||
<div class="rail-eyebrow">Admin</div>
|
||||
<NavLink class="rail-link" href="/apikeys" Match="NavLinkMatch.Prefix">API keys</NavLink>
|
||||
<NavLink class="rail-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
|
||||
<NavSection Title="Galaxy"
|
||||
Expanded="@_expanded.Contains("galaxy")"
|
||||
OnToggle="@(() => ToggleAsync("galaxy"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
|
||||
<div class="rail-foot">
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<div class="rail-user">@authState.User.Identity?.Name</div>
|
||||
<div class="rail-roles">
|
||||
@string.Join(", ", authState.User.Claims
|
||||
.Where(c => c.Type == System.Security.Claims.ClaimTypes.Role)
|
||||
.Select(c => c.Value))
|
||||
</div>
|
||||
<form method="post" action="/logout">
|
||||
<AntiforgeryToken />
|
||||
<button class="rail-btn" type="submit">Sign out</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-btn" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
<NavSection Title="Admin"
|
||||
Expanded="@_expanded.Contains("admin")"
|
||||
OnToggle="@(() => ToggleAsync("admin"))">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/apikeys" Match="NavLinkMatch.Prefix">API Keys</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
|
||||
</li>
|
||||
</NavSection>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="border-top px-3 py-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-body-secondary small">@authState.User.Identity?.Name</span>
|
||||
<form method="post" action="/logout" data-enhance="false">
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="border-top px-3 py-2">
|
||||
<a href="/login" class="btn btn-outline-secondary btn-sm py-0 px-2 w-100">Sign In</a>
|
||||
</div>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<main class="page">
|
||||
<main class="page flex-grow-1">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// Sections whose collapsed/expanded state we persist. Acts as the allow-list
|
||||
// when parsing the cookie so stale or attacker-supplied ids are ignored.
|
||||
private static readonly string[] SectionIds = { "runtime", "galaxy", "admin" };
|
||||
|
||||
// The currently-expanded sections. Populated from the cookie on first
|
||||
// render; mutated by ToggleAsync and by navigating into a section.
|
||||
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Hydrate from the cookie. Until this completes the sidebar paints
|
||||
// collapsed, matching the CentralUI behaviour.
|
||||
string saved;
|
||||
try
|
||||
{
|
||||
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in saved.Split(
|
||||
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (Array.IndexOf(SectionIds, id) >= 0)
|
||||
{
|
||||
_expanded.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// The section of the page we loaded on is always expanded.
|
||||
if (EnsureCurrentSectionExpanded())
|
||||
{
|
||||
await PersistAsync();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
if (EnsureCurrentSectionExpanded())
|
||||
{
|
||||
_ = PersistAsync();
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleAsync(string id)
|
||||
{
|
||||
if (!_expanded.Remove(id))
|
||||
{
|
||||
_expanded.Add(id);
|
||||
}
|
||||
|
||||
await PersistAsync();
|
||||
}
|
||||
|
||||
// Adds the current page's section to _expanded; returns true if it changed.
|
||||
private bool EnsureCurrentSectionExpanded()
|
||||
{
|
||||
var section = CurrentSection();
|
||||
return section is not null && _expanded.Add(section);
|
||||
}
|
||||
|
||||
// Maps the current URL's first path segment to a section id, or null for
|
||||
// sectionless pages (Dashboard, Login).
|
||||
private string? CurrentSection()
|
||||
{
|
||||
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||
var firstSegment = relative.Split('?', '#')[0]
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault();
|
||||
|
||||
return firstSegment switch
|
||||
{
|
||||
"sessions" or "workers" or "events" or "alarms" => "runtime",
|
||||
"galaxy" or "browse" => "galaxy",
|
||||
"apikeys" or "settings" => "admin",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PersistAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
// The circuit is gone — nothing to persist to.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Navigation.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
@* A collapsible sidebar nav section. The header is a full-width button that
|
||||
toggles ChildContent visibility. Pattern lifted from ScadaLink CentralUI
|
||||
(Components/Layout/NavSection.razor) — see [[project-deployed-service]]. *@
|
||||
|
||||
<li class="nav-item">
|
||||
<button type="button"
|
||||
class="nav-section-toggle"
|
||||
@onclick="OnToggle"
|
||||
aria-expanded="@(Expanded ? "true" : "false")">
|
||||
<span class="chevron" aria-hidden="true">@(Expanded ? "▾" : "▸")</span>
|
||||
<span>@Title</span>
|
||||
</button>
|
||||
</li>
|
||||
@if (Expanded)
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Section label shown in the header (e.g. "Runtime").</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the section is expanded — its items rendered.</summary>
|
||||
[Parameter]
|
||||
public bool Expanded { get; set; }
|
||||
|
||||
/// <summary>Raised when the header button is clicked.</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnToggle { get; set; }
|
||||
|
||||
/// <summary>The section's nav items, rendered only while expanded.</summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardApiKeyAuthorization
|
||||
{
|
||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
||||
/// <param name="user">The authenticated user principal.</param>
|
||||
public bool CanManage(ClaimsPrincipal user)
|
||||
{
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
|
||||
@@ -5,11 +5,18 @@ public sealed record DashboardApiKeyManagementResult(
|
||||
string Message,
|
||||
string? ApiKey)
|
||||
{
|
||||
/// <summary>Creates a successful result with an optional API key.</summary>
|
||||
/// <param name="message">The success message.</param>
|
||||
/// <param name="apiKey">The API key if generated or modified.</param>
|
||||
/// <returns>A successful result record.</returns>
|
||||
public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null)
|
||||
{
|
||||
return new DashboardApiKeyManagementResult(true, message, apiKey);
|
||||
}
|
||||
|
||||
/// <summary>Creates a failed result with an error message.</summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>A failed result record.</returns>
|
||||
public static DashboardApiKeyManagementResult Fail(string message)
|
||||
{
|
||||
return new DashboardApiKeyManagementResult(false, message, null);
|
||||
|
||||
@@ -14,11 +14,17 @@ public sealed class DashboardApiKeyManagementService(
|
||||
{
|
||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||
|
||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
||||
/// <param name="user">The authenticated user principal.</param>
|
||||
public bool CanManage(ClaimsPrincipal user)
|
||||
{
|
||||
return authorization.CanManage(user);
|
||||
}
|
||||
|
||||
/// <summary>Creates an API key asynchronously.</summary>
|
||||
/// <param name="user">The authenticated user principal.</param>
|
||||
/// <param name="request">The request payload.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public async Task<DashboardApiKeyManagementResult> CreateAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardApiKeyManagementRequest request,
|
||||
@@ -67,6 +73,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Revokes an API key asynchronously.</summary>
|
||||
/// <param name="user">The authenticated user principal.</param>
|
||||
/// <param name="keyId">The API key identifier.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
@@ -100,6 +110,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
||||
}
|
||||
|
||||
/// <summary>Rotates an API key secret asynchronously.</summary>
|
||||
/// <param name="user">The authenticated user principal.</param>
|
||||
/// <param name="keyId">The API key identifier.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public async Task<DashboardApiKeyManagementResult> RotateAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
@@ -143,6 +157,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes a revoked API key asynchronously.</summary>
|
||||
/// <param name="user">The authenticated user principal.</param>
|
||||
/// <param name="keyId">The API key identifier.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public async Task<DashboardApiKeyManagementResult> DeleteAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
|
||||
@@ -35,5 +35,5 @@ public static class DashboardAuthenticationDefaults
|
||||
|
||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
public const string CookieName = "__Host-MxGatewayDashboard";
|
||||
public const string CookieName = "MxGatewayDashboard";
|
||||
}
|
||||
|
||||
@@ -117,6 +117,8 @@ public sealed class DashboardAuthenticator(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Escapes special characters in LDAP filter strings.</summary>
|
||||
/// <param name="value">The string value to escape.</param>
|
||||
internal static string EscapeLdapFilter(string value)
|
||||
{
|
||||
StringBuilder builder = new(value.Length);
|
||||
@@ -141,6 +143,8 @@ public sealed class DashboardAuthenticator(
|
||||
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
||||
/// an empty list when no group matches (caller rejects the login).
|
||||
/// </summary>
|
||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||
IEnumerable<string> groups,
|
||||
IReadOnlyDictionary<string, string> groupToRole)
|
||||
@@ -171,6 +175,8 @@ public sealed class DashboardAuthenticator(
|
||||
return [.. roles];
|
||||
}
|
||||
|
||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||
{
|
||||
int equalsIndex = distinguishedName.IndexOf('=');
|
||||
|
||||
@@ -8,11 +8,14 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// </summary>
|
||||
public sealed class DashboardAuthorizationRequirement(IReadOnlyList<string> requiredRoles) : IAuthorizationRequirement
|
||||
{
|
||||
/// <summary>Gets the list of roles required to satisfy this requirement.</summary>
|
||||
public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles;
|
||||
|
||||
/// <summary>Gets a requirement satisfied by any dashboard role (admin or viewer).</summary>
|
||||
public static DashboardAuthorizationRequirement AnyDashboardRole { get; } =
|
||||
new([DashboardRoles.Admin, DashboardRoles.Viewer]);
|
||||
|
||||
/// <summary>Gets a requirement satisfied only by the admin role.</summary>
|
||||
public static DashboardAuthorizationRequirement AdminOnly { get; } =
|
||||
new([DashboardRoles.Admin]);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public static class DashboardConnectionStringDisplay
|
||||
{
|
||||
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
|
||||
/// <param name="connectionString">The connection string to sanitize.</param>
|
||||
public static string GalaxyRepositoryConnectionString(string connectionString)
|
||||
{
|
||||
try
|
||||
|
||||
+13
-4
@@ -191,11 +191,22 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
</section>
|
||||
""";
|
||||
|
||||
return RenderPage("Dashboard Sign In", body);
|
||||
return RenderPage("Dashboard Sign In", heading: null, body);
|
||||
}
|
||||
|
||||
private static string RenderPage(string title, string body)
|
||||
=> RenderPage(title, heading: title, body);
|
||||
|
||||
private static string RenderPage(string title, string? heading, string body)
|
||||
{
|
||||
string headingHtml = string.IsNullOrEmpty(heading)
|
||||
? string.Empty
|
||||
: $"""
|
||||
<div class="dashboard-page-header">
|
||||
<h1>{HtmlEncoder.Default.Encode(heading)}</h1>
|
||||
</div>
|
||||
""";
|
||||
|
||||
return $"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -212,9 +223,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
<span class="brand"><span class="mark">▮</span> MXAccess Gateway</span>
|
||||
</header>
|
||||
<main class="page">
|
||||
<div class="dashboard-page-header">
|
||||
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
|
||||
</div>
|
||||
{headingHtml}
|
||||
{body}
|
||||
</main>
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
|
||||
internal static class DashboardGalaxyProjector
|
||||
{
|
||||
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||
{
|
||||
return entry.DashboardSummary;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
@@ -39,8 +41,10 @@ public static class DashboardServiceCollectionExtensions
|
||||
{
|
||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||
cookieOptions.Cookie.HttpOnly = true;
|
||||
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
||||
// SecurePolicy is bound via PostConfigure below so it can honour
|
||||
// DashboardOptions.RequireHttpsCookie (default Always; dev HTTP
|
||||
// deployments set RequireHttpsCookie=false to use SameAsRequest).
|
||||
cookieOptions.Cookie.Path = "/";
|
||||
cookieOptions.LoginPath = "/login";
|
||||
cookieOptions.LogoutPath = "/logout";
|
||||
@@ -52,6 +56,14 @@ public static class DashboardServiceCollectionExtensions
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
||||
_ => { });
|
||||
|
||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
||||
{
|
||||
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
});
|
||||
|
||||
services.AddAuthorization(authorization =>
|
||||
{
|
||||
authorization.AddPolicy(
|
||||
|
||||
@@ -4,11 +4,15 @@ public sealed record DashboardSessionAdminResult(
|
||||
bool Succeeded,
|
||||
string Message)
|
||||
{
|
||||
/// <summary>Creates a successful result with the given message.</summary>
|
||||
/// <param name="message">The result message.</param>
|
||||
public static DashboardSessionAdminResult Success(string message)
|
||||
{
|
||||
return new DashboardSessionAdminResult(true, message);
|
||||
}
|
||||
|
||||
/// <summary>Creates a failed result with the given message.</summary>
|
||||
/// <param name="message">The result message.</param>
|
||||
public static DashboardSessionAdminResult Fail(string message)
|
||||
{
|
||||
return new DashboardSessionAdminResult(false, message);
|
||||
|
||||
@@ -35,8 +35,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
/// <param name="metrics">Gateway metrics collector.</param>
|
||||
/// <param name="configurationProvider">Gateway configuration provider.</param>
|
||||
/// <param name="galaxyHierarchyCache">Galaxy hierarchy cache.</param>
|
||||
/// <param name="apiKeyAdminStore">API key administration store.</param>
|
||||
/// <param name="options">Gateway configuration options.</param>
|
||||
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
|
||||
/// <param name="logger">Optional logger for the dashboard snapshot service.</param>
|
||||
public DashboardSnapshotService(
|
||||
ISessionRegistry sessionRegistry,
|
||||
GatewayMetrics metrics,
|
||||
|
||||
@@ -14,6 +14,11 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<Authen
|
||||
{
|
||||
private readonly HubTokenService _tokens;
|
||||
|
||||
/// <summary>Initializes a new hub token authentication handler.</summary>
|
||||
/// <param name="options">The options monitor for the authentication scheme.</param>
|
||||
/// <param name="logger">The logger factory for logging authentication events.</param>
|
||||
/// <param name="encoder">The URL encoder for encoding authentication values.</param>
|
||||
/// <param name="tokens">The hub token service for validating tokens.</param>
|
||||
public HubTokenAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
@@ -24,6 +29,7 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<Authen
|
||||
_tokens = tokens;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
string? token = ExtractToken();
|
||||
|
||||
@@ -30,13 +30,16 @@ public sealed class HubTokenService
|
||||
|
||||
private readonly ITimeLimitedDataProtector _protector;
|
||||
|
||||
/// <summary>Initializes a new instance of the HubTokenService with a data protection provider.</summary>
|
||||
/// <param name="dataProtection">The data protection provider for token encryption.</param>
|
||||
public HubTokenService(IDataProtectionProvider dataProtection)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataProtection);
|
||||
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
|
||||
}
|
||||
|
||||
/// <summary>Issue a bearer token carrying the user's identity and roles.</summary>
|
||||
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
|
||||
/// <param name="user">The claims principal representing the user.</param>
|
||||
public string Issue(ClaimsPrincipal user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
@@ -47,7 +50,8 @@ public sealed class HubTokenService
|
||||
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
|
||||
}
|
||||
|
||||
/// <summary>Validate a token and return the equivalent <see cref="ClaimsPrincipal"/>; null when invalid or expired.</summary>
|
||||
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
|
||||
/// <param name="token">The token string to validate.</param>
|
||||
public ClaimsPrincipal? Validate(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class AlarmsHub : Hub
|
||||
/// <summary>Method name used to push <c>AlarmFeedMessage</c> values to clients.</summary>
|
||||
public const string AlarmMessage = "AlarmFeed";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false);
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class AlarmsHubPublisher(
|
||||
IHubContext<AlarmsHub> hubContext,
|
||||
ILogger<AlarmsHubPublisher> logger) : BackgroundService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Loop forever — when StreamAsync completes (monitor restart, etc.)
|
||||
|
||||
@@ -14,6 +14,9 @@ public sealed class DashboardEventBroadcaster(
|
||||
IHubContext<EventsHub> hubContext,
|
||||
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
|
||||
{
|
||||
/// <summary>Publishes an MX event to connected dashboard clients.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="mxEvent">The MX event to publish.</param>
|
||||
public void Publish(string sessionId, MxEvent mxEvent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class DashboardHubConnectionFactory(
|
||||
HubTokenService tokens,
|
||||
AuthenticationStateProvider authState)
|
||||
{
|
||||
/// <summary>Creates a new hub connection to the specified hub path.</summary>
|
||||
/// <param name="hubPath">The relative hub path (e.g., "/hubs/snapshot").</param>
|
||||
/// <returns>A configured hub connection with automatic reconnection and token authentication.</returns>
|
||||
public HubConnection Create(string hubPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(hubPath);
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotServi
|
||||
/// <summary>Method name used to push snapshot updates to clients.</summary>
|
||||
public const string SnapshotMessage = "SnapshotUpdated";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false);
|
||||
|
||||
@@ -26,6 +26,10 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
|
||||
private readonly ILogger<DashboardSnapshotPublisher> _logger;
|
||||
private readonly TimeSpan _reconnectDelay;
|
||||
|
||||
/// <summary>Initializes a new instance of the DashboardSnapshotPublisher class.</summary>
|
||||
/// <param name="snapshotService">The snapshot service to subscribe to.</param>
|
||||
/// <param name="hubContext">The SignalR hub context for broadcasting.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public DashboardSnapshotPublisher(
|
||||
IDashboardSnapshotService snapshotService,
|
||||
IHubContext<DashboardSnapshotHub> hubContext,
|
||||
@@ -34,9 +38,12 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
|
||||
{
|
||||
}
|
||||
|
||||
// Internal hook for the Server-042 regression test: tests inject a
|
||||
// very short reconnect delay so the assertion doesn't wait the full
|
||||
// 5s. Production wiring always uses the 5s default via the public ctor.
|
||||
/// <summary>Initializes a new instance of the DashboardSnapshotPublisher class with custom reconnect delay.</summary>
|
||||
/// <remarks>Internal hook for testing: tests inject a very short reconnect delay so assertions don't wait full 5s.</remarks>
|
||||
/// <param name="snapshotService">The snapshot service to subscribe to.</param>
|
||||
/// <param name="hubContext">The SignalR hub context for broadcasting.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="reconnectDelay">The delay before reconnecting after a subscription failure.</param>
|
||||
internal DashboardSnapshotPublisher(
|
||||
IDashboardSnapshotService snapshotService,
|
||||
IHubContext<DashboardSnapshotHub> hubContext,
|
||||
@@ -49,6 +56,7 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
|
||||
_reconnectDelay = reconnectDelay;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Loop forever — when WatchSnapshotsAsync completes or throws, reopen
|
||||
|
||||
@@ -21,6 +21,9 @@ public sealed class EventsHub : Hub
|
||||
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
|
||||
public const string EventMessage = "MxEvent";
|
||||
|
||||
/// <summary>Computes the SignalR group name for a given session id.</summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <returns>The group name for the session.</returns>
|
||||
public static string GroupName(string sessionId) => $"session:{sessionId}";
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +59,9 @@ public sealed class EventsHub : Hub
|
||||
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId));
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes the calling SignalR connection from the per-session events group.</summary>
|
||||
/// <param name="sessionId">Session id to unsubscribe the caller from.</param>
|
||||
/// <returns>A task representing the unsubscription operation.</returns>
|
||||
public Task UnsubscribeSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
|
||||
@@ -10,5 +10,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// </summary>
|
||||
public interface IDashboardEventBroadcaster
|
||||
{
|
||||
/// <summary>Publishes an MxEvent to all dashboard clients subscribed to the session.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="mxEvent">The MxEvent to publish.</param>
|
||||
void Publish(string sessionId, MxEvent mxEvent);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,46 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public interface IDashboardApiKeyManagementService
|
||||
{
|
||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
||||
/// <param name="user">The user principal.</param>
|
||||
/// <returns>True if the user can manage keys; otherwise false.</returns>
|
||||
bool CanManage(ClaimsPrincipal user);
|
||||
|
||||
/// <summary>Creates a new API key.</summary>
|
||||
/// <param name="user">The user principal.</param>
|
||||
/// <param name="request">The key creation request details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The creation result.</returns>
|
||||
Task<DashboardApiKeyManagementResult> CreateAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardApiKeyManagementRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Revokes an existing API key.</summary>
|
||||
/// <param name="user">The user principal.</param>
|
||||
/// <param name="keyId">The key identifier to revoke.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The revocation result.</returns>
|
||||
Task<DashboardApiKeyManagementResult> RevokeAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Rotates an existing API key.</summary>
|
||||
/// <param name="user">The user principal.</param>
|
||||
/// <param name="keyId">The key identifier to rotate.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The rotation result.</returns>
|
||||
Task<DashboardApiKeyManagementResult> RotateAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Deletes an API key.</summary>
|
||||
/// <param name="user">The user principal.</param>
|
||||
/// <param name="keyId">The key identifier to delete.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The deletion result.</returns>
|
||||
Task<DashboardApiKeyManagementResult> DeleteAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
|
||||
@@ -6,9 +6,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
public interface IDashboardAuthenticator
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates the dashboard session with an API key.
|
||||
/// Authenticates the dashboard session with credentials.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">The API key to authenticate.</param>
|
||||
/// <param name="username">Username to authenticate.</param>
|
||||
/// <param name="password">Password to authenticate.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||
string? username,
|
||||
|
||||
Reference in New Issue
Block a user