-
@authState.User.Identity?.Name
+
+ ▮ MXAccess Gateway
+ ›
+ gateway dashboard
+
+
+
+ @authState.User.Identity?.Name
+
+ signed in
+
+
+
+
+ signed out
+
+
+
+
+
+
+
+
+
@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);
+ }
+}