From 65943597d479369cada8e520f33eba229ab0de0f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 01:48:27 -0400 Subject: [PATCH] dashboard: side-rail layout + SignalR push hubs (snapshot, alarms, events) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout ------ DashboardLayout.razor replaces the inline header nav with a left side rail modelled on the OtOpcUa admin (Dashboard B). The top bar keeps only the brand, breadcrumb, and signed-in status pill; navigation moves into a fixed-width 218px rail with grouped section eyebrows (Overview, Runtime, Galaxy, Admin) and a Session footer carrying the user name, role claims, and a Sign-out button. dashboard.css gains the `.app-shell` flex container, `.side-rail` column, `.rail-eyebrow`, `.rail-link[.active]`, `.rail-foot`, `.rail-user`, `.rail-roles`, and `.rail-btn` rules (all driven by the existing theme.css tokens, no new hard-coded colours). SignalR (push) -------------- Adds three hubs under `Dashboard/Hubs/`, all gated by the `HubClientsPolicy` registered in the previous commit: * DashboardSnapshotHub (/hubs/snapshot) Broadcasts the full DashboardSnapshot on every change. Sends the current snapshot to a new caller in OnConnectedAsync so the first paint is immediate. * AlarmsHub (/hubs/alarms) Connected clients auto-join the `__alarms__` group. Receives AlarmFeedMessage values (active_alarm / snapshot_complete / transition) re-broadcast from the gateway's central alarm monitor. * EventsHub (/hubs/events) Per-session push surface. Clients call SubscribeSession(sessionId) to join `session:{id}`. The publisher side is intentionally a follow-up — the snapshot hub already carries recent-events rollups; a dedicated MxEvent broadcaster on EventStreamService will plug into this hub's group convention. Two BackgroundService publishers wire server-side data sources to the hubs: * DashboardSnapshotPublisher subscribes to `IDashboardSnapshotService.WatchSnapshotsAsync` and forwards every snapshot to all connected hub clients. * AlarmsHubPublisher subscribes to `IGatewayAlarmService.StreamAsync` (no filter) and forwards every AlarmFeedMessage to the `__alarms__` group, reconnecting with a 5-second backoff if the stream faults. Connection + auth plumbing -------------------------- * `GET /hubs/token` issues a fresh data-protected bearer token bound to the calling user's identity and roles. Gated by the cookie-only ViewerPolicy so a Blazor circuit (cookie-authenticated) can mint a token, but a hub bearer cannot self-bootstrap a new one. * DashboardHubConnectionFactory (scoped) is the client-side helper Razor pages inject. It builds a HubConnection with an AccessTokenProvider that calls HubTokenService.Issue on every (re)connect — keeps the connection alive across cookie refresh boundaries. Pull → push refactor -------------------- DashboardPageBase no longer drives its own `WatchSnapshotsAsync` async-foreach loop. It now: 1. seeds Snapshot synchronously from `IDashboardSnapshotService.GetSnapshot()` so the first render is non-empty; 2. opens a `DashboardSnapshotHub` connection via the connection factory; 3. updates Snapshot + triggers StateHasChanged on each `SnapshotUpdated` push. The hub connection is best-effort: if SignalR can't start, the synchronous snapshot seed keeps the UI populated. SignalR's WithAutomaticReconnect handles the recovery path. Package ------- Adds `Microsoft.AspNetCore.SignalR.Client` 10.0.0 to the server csproj so the in-process Blazor pages can open hub connections back to their own hosting process. Verification: 475 server tests (+ 2 new `DashboardHubsRegistrationTests` that pin the hub negotiate endpoints and the singleton/scoped DI shape), 275 worker tests (+ 9 dev-rig skips), 18 integration tests (live MxAccess + LDAP + Galaxy) all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Dashboard/Components/DashboardPageBase.cs | 67 +++++++----- .../Components/Layout/DashboardLayout.razor | 86 ++++++++++----- ...DashboardEndpointRouteBuilderExtensions.cs | 23 ++++ .../DashboardServiceCollectionExtensions.cs | 3 + .../Dashboard/Hubs/AlarmsHub.cs | 25 +++++ .../Dashboard/Hubs/AlarmsHubPublisher.cs | 68 ++++++++++++ .../Hubs/DashboardHubConnectionFactory.cs | 36 +++++++ .../Dashboard/Hubs/DashboardSnapshotHub.cs | 23 ++++ .../Hubs/DashboardSnapshotPublisher.cs | 41 +++++++ .../Dashboard/Hubs/EventsHub.cs | 45 ++++++++ .../ZB.MOM.WW.MxGateway.Server.csproj | 1 + .../wwwroot/css/dashboard.css | 102 ++++++++++++++---- .../DashboardHubsRegistrationTests.cs | 44 ++++++++ 13 files changed, 494 insertions(+), 70 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardHubConnectionFactory.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardPageBase.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardPageBase.cs index 400fa64..f323bd4 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardPageBase.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/DashboardPageBase.cs @@ -1,62 +1,79 @@ using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components; /// -/// Base class for Blazor dashboard pages that watch gateway metrics snapshots. +/// Base class for Blazor dashboard pages that watch gateway metrics +/// snapshots. The previous implementation polled +/// directly; we +/// now subscribe to so updates are +/// pushed and disconnects survive reconnects via SignalR's +/// auto-reconnect. /// public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable { - private readonly CancellationTokenSource _disposeCancellation = new(); - private Task? _watchTask; + private HubConnection? _hub; - /// - /// Service that provides gateway metric snapshots. - /// + /// Snapshot service used to seed the initial render before the hub connects. [Inject] protected IDashboardSnapshotService SnapshotService { get; set; } = null!; + /// Factory that builds the SignalR connection (mints the hub bearer token). + [Inject] + protected DashboardHubConnectionFactory HubFactory { get; set; } = null!; + /// - /// The most recent gateway metric snapshot, updated as it changes. + /// The most recent gateway metric snapshot. Synchronously seeded from + /// for the very + /// first render, then refreshed by hub push. /// protected DashboardSnapshot? Snapshot { get; private set; } /// - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - _watchTask = WatchSnapshotsAsync(); + Snapshot = SnapshotService.GetSnapshot(); + await ConnectHubAsync().ConfigureAwait(false); } /// public async ValueTask DisposeAsync() { - await _disposeCancellation.CancelAsync().ConfigureAwait(false); - if (_watchTask is not null) + if (_hub is not null) { - await _watchTask.ConfigureAwait(false); + try + { + await _hub.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Disposal-time errors are best-effort. + } } - _disposeCancellation.Dispose(); GC.SuppressFinalize(this); } - /// - /// Watches snapshot changes and triggers component refresh. - /// - private async Task WatchSnapshotsAsync() + private async Task ConnectHubAsync() { + _hub = HubFactory.Create("/hubs/snapshot"); + _hub.On(DashboardSnapshotHub.SnapshotMessage, async snapshot => + { + Snapshot = snapshot; + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + }); + try { - await foreach (DashboardSnapshot snapshot in SnapshotService - .WatchSnapshotsAsync(_disposeCancellation.Token) - .ConfigureAwait(false)) - { - Snapshot = snapshot; - await InvokeAsync(StateHasChanged).ConfigureAwait(false); - } + await _hub.StartAsync().ConfigureAwait(false); } - catch (OperationCanceledException) when (_disposeCancellation.IsCancellationRequested) + catch { + // Hub is best-effort; the initial GetSnapshot() seed remains + // valid and the snapshot service keeps populating its cache for + // the next reconnect cycle. } } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor index 355556c..8675a69 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor @@ -1,35 +1,67 @@ @inherits LayoutComponentBase -
-
- MXAccess Gateway - - - - -
- @authState.User.Identity?.Name +
+ MXAccess Gateway + + gateway dashboard + + + + @authState.User.Identity?.Name + + signed in + + + + + signed out + + + +
+ +
+
+ + +
Session
+ Sign in +
+ +
+ +
@Body
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index 061f322..f214778 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http.HttpResults; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard.Components; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; @@ -49,6 +50,28 @@ public static class DashboardEndpointRouteBuilderExtensions .AllowAnonymous() .WithName("DashboardAccessDenied"); + // SignalR hubs. Authorization is enforced on the hub class via + // [Authorize(Policy = HubClientsPolicy)] so the policy accepts either + // the dashboard cookie or a HubToken bearer. + endpoints.MapHub("/hubs/snapshot"); + endpoints.MapHub("/hubs/alarms"); + endpoints.MapHub("/hubs/events"); + + // Bearer-token mint endpoint. The cookie-authenticated browser hits + // this from JS before opening a hub connection; the hub then accepts + // the returned token via the HubToken scheme. Restricting access to + // the cookie scheme keeps the bearer issuance path from being + // self-bootstrapped from a previous bearer. + endpoints.MapGet( + "/hubs/token", + (HttpContext httpContext, HubTokenService tokens) => + { + string token = tokens.Issue(httpContext.User); + return Results.Json(new { token }); + }) + .RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy) + .WithName("DashboardHubToken"); + // Every dashboard Razor component requires a viewer-or-admin role. The // login/logout/denied endpoints above opt out via AllowAnonymous(); an // unauthenticated request to a component route is challenged by the diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index f143721..350540f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -21,6 +21,9 @@ public static class DashboardServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); + services.AddHostedService(); + services.AddHostedService(); services.AddHttpContextAccessor(); services.AddAntiforgery(); services.AddCascadingAuthenticationState(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs new file mode 100644 index 0000000..10edab1 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +/// +/// SignalR hub that pushes alarm-feed messages from the gateway's +/// central alarm monitor. Connected clients auto-join +/// on connect and receive every +/// AlarmFeedMessage the monitor emits. +/// +[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)] +public sealed class AlarmsHub : Hub +{ + public const string AllAlarmsGroup = "__alarms__"; + + /// Method name used to push AlarmFeedMessage values to clients. + public const string AlarmMessage = "AlarmFeed"; + + public override async Task OnConnectedAsync() + { + await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false); + await base.OnConnectedAsync().ConfigureAwait(false); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs new file mode 100644 index 0000000..9989bb8 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.SignalR; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Alarms; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +/// +/// Background service that subscribes to +/// (no filter) and re-broadcasts +/// every to every +/// client. The hub itself is session-less; clients filter / route messages +/// in the browser. +/// +public sealed class AlarmsHubPublisher( + IGatewayAlarmService alarmService, + IHubContext hubContext, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Loop forever — when StreamAsync completes (monitor restart, etc.) + // reopen the subscription. The hosted-service lifetime ends only + // when the host stops. + while (!stoppingToken.IsCancellationRequested) + { + try + { + await foreach (AlarmFeedMessage message in alarmService + .StreamAsync(alarmFilterPrefix: null, stoppingToken) + .ConfigureAwait(false)) + { + if (stoppingToken.IsCancellationRequested) + { + break; + } + + try + { + await hubContext.Clients + .Group(AlarmsHub.AllAlarmsGroup) + .SendAsync(AlarmsHub.AlarmMessage, message, stoppingToken) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Alarm broadcast failed; continuing."); + } + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Alarm subscription faulted; reconnecting in 5s."); + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + } + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardHubConnectionFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardHubConnectionFactory.cs new file mode 100644 index 0000000..fa7bc8d --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardHubConnectionFactory.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.SignalR.Client; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +/// +/// Client-side helper that builds a targeted at a +/// dashboard hub. Mints a fresh data-protected bearer token via +/// on every (re)connect so the connection +/// authenticates against +/// without needing to forward the browser's HttpOnly cookie. +/// +public sealed class DashboardHubConnectionFactory( + NavigationManager navigation, + HubTokenService tokens, + AuthenticationStateProvider authState) +{ + public HubConnection Create(string hubPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(hubPath); + + Uri hubUrl = navigation.ToAbsoluteUri(hubPath); + return new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.AccessTokenProvider = async () => + { + AuthenticationState state = await authState.GetAuthenticationStateAsync().ConfigureAwait(false); + return tokens.Issue(state.User); + }; + }) + .WithAutomaticReconnect() + .Build(); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs new file mode 100644 index 0000000..a902e25 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +/// +/// SignalR hub that pushes a fresh on every +/// snapshot refresh. New connections receive the current snapshot +/// immediately via ; subsequent refreshes are +/// broadcast by . +/// +[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)] +public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotService) : Hub +{ + /// Method name used to push snapshot updates to clients. + public const string SnapshotMessage = "SnapshotUpdated"; + + public override async Task OnConnectedAsync() + { + await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false); + await base.OnConnectedAsync().ConfigureAwait(false); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs new file mode 100644 index 0000000..9f74e91 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.SignalR; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +/// +/// Background service that subscribes to +/// and broadcasts every snapshot it produces to every connected +/// client. There is one publisher per +/// gateway process; clients listen via the hub. +/// +public sealed class DashboardSnapshotPublisher( + IDashboardSnapshotService snapshotService, + IHubContext hubContext, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await foreach (DashboardSnapshot snapshot in snapshotService + .WatchSnapshotsAsync(stoppingToken) + .ConfigureAwait(false)) + { + try + { + await hubContext.Clients + .All + .SendAsync(DashboardSnapshotHub.SnapshotMessage, snapshot, stoppingToken) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Snapshot broadcast failed; will retry on the next snapshot tick."); + } + } + } + catch (OperationCanceledException) + { + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs new file mode 100644 index 0000000..7ad14b8 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +/// +/// SignalR hub for per-session MxEvent push. Clients call +/// to join the group for a specific +/// session; the dashboard's MxEvent broadcaster (a future hook on +/// EventStreamService) sends messages to session:{id}. +/// +/// +/// The publisher side is intentionally a follow-up. Today the dashboard's +/// per-session event view is fed by the snapshot hub, which carries the +/// rolling recent-events list. Once a dedicated MxEvent broadcaster +/// lands, this hub's group convention is what it will publish to. +/// +[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)] +public sealed class EventsHub : Hub +{ + /// Method name used to push individual MxEvent values to clients. + public const string EventMessage = "MxEvent"; + + public static string GroupName(string sessionId) => $"session:{sessionId}"; + + public Task SubscribeSession(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return Task.CompletedTask; + } + + return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId)); + } + + public Task UnsubscribeSession(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return Task.CompletedTask; + } + + return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(sessionId)); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj index eefe591..bbce9d8 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj +++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj @@ -6,6 +6,7 @@ + diff --git a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css index 47ef3aa..b255318 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css +++ b/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css @@ -10,33 +10,99 @@ body.dashboard-body { min-height: 100vh; } /* ── App bar ───────────────────────────────────────────────────────────────── theme.css styles .app-bar / .brand / .mark / .spacer. Here we centre the row - and add the inline nav and the signed-in-user cluster. */ -.app-bar { align-items: center; gap: 1.25rem; } + and add the meta cluster. Navigation lives in the side rail below. */ +.app-bar { align-items: center; gap: 1rem; } .app-bar .brand { color: var(--ink); } .app-bar .brand:hover { text-decoration: none; } -.app-nav { display: flex; flex-wrap: wrap; gap: 0.15rem; } -.app-nav a { - font-size: 0.82rem; - color: var(--ink-soft); - padding: 0.25rem 0.6rem; - border-radius: 4px; +/* ── App shell ─────────────────────────────────────────────────────────────── + Two-column layout: fixed-width side rail (218px) + flexible main column. */ +.app-shell { + display: flex; + align-items: stretch; + min-height: calc(100vh - 3.3rem); } -.app-nav a:hover { color: var(--ink); background: #f0f0ec; text-decoration: none; } -.app-nav a.active { +.app-shell > .page { flex: 1; min-width: 0; } + +/* ── Side rail ─────────────────────────────────────────────────────────────── + Left-rail navigation with eyebrow section headings, a footer session + block, and active-route accent. */ +.side-rail { + width: 218px; + flex: 0 0 218px; + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 1rem 0.7rem; + background: var(--card); + border-right: 1px solid var(--rule-strong); +} + +.rail-eyebrow { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); + padding: 0.4rem 0.6rem 0.2rem; +} + +.rail-link { + display: block; + padding: 0.4rem 0.6rem; + border-radius: 4px; + border-left: 2px solid transparent; + font-size: 0.86rem; + color: var(--ink-soft); + text-decoration: none; +} +.rail-link:hover { + background: #f3f6fd; + color: var(--ink); + text-decoration: none; +} +.rail-link.active { + background: #eef2fc; + border-left-color: var(--accent); color: var(--accent-deep); - background: #e7ecfb; font-weight: 600; } -.app-user { - display: flex; - align-items: center; - gap: 0.6rem; - font-size: 0.8rem; - color: var(--ink-soft); +.rail-foot { + margin-top: auto; + padding-top: 0.6rem; + border-top: 1px solid var(--rule); +} +.rail-user { + padding: 0 0.6rem; + font-weight: 600; + font-size: 0.88rem; + color: var(--ink); +} +.rail-roles { + padding: 0.1rem 0.6rem 0.5rem; + font-family: var(--mono); + font-size: 0.72rem; + color: var(--ink-faint); +} +.rail-btn { + display: inline-block; + margin: 0 0.6rem; + padding: 0.3rem 0.7rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--ink-soft); + background: var(--card); + border: 1px solid var(--rule-strong); + border-radius: 4px; + cursor: pointer; + text-decoration: none; +} +.rail-btn:hover { + border-color: var(--accent); + color: var(--accent); + text-decoration: none; } -.app-user form { margin: 0; } /* ── Page header ───────────────────────────────────────────────────────────── h1 in sans, the sub-line in monospace as a quiet meta crumb. */ diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs new file mode 100644 index 0000000..0b8f45b --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardHubsRegistrationTests.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.MxGateway.Server; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +public sealed class DashboardHubsRegistrationTests +{ + [Fact] + public async Task Build_WhenDashboardEnabled_MapsAllThreeHubsAndTokenEndpoint() + { + await using WebApplication app = GatewayApplication.Build([]); + IReadOnlyList endpoints = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(source => source.Endpoints) + .OfType() + .ToList(); + + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/hubs/snapshot/negotiate"); + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/hubs/alarms/negotiate"); + Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/hubs/events/negotiate"); + Assert.Contains(endpoints, endpoint => + endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardHubToken"); + } + + [Fact] + public async Task Build_WhenDashboardEnabled_RegistersHubTokenServiceAndConnectionFactory() + { + await using WebApplication app = GatewayApplication.Build([]); + + // HubTokenService is singleton; DashboardHubConnectionFactory is scoped + // (it captures NavigationManager and AuthenticationStateProvider which + // are themselves per-circuit). + HubTokenService tokens = app.Services.GetRequiredService(); + Assert.NotNull(tokens); + + using IServiceScope scope = app.Services.CreateScope(); + DashboardHubConnectionFactory factory = scope.ServiceProvider + .GetRequiredService(); + Assert.NotNull(factory); + } +}