dashboard: side-rail layout + SignalR push hubs (snapshot, alarms, events)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/> directly; we
|
||||
/// now subscribe to <see cref="DashboardSnapshotHub"/> so updates are
|
||||
/// pushed and disconnects survive reconnects via SignalR's
|
||||
/// auto-reconnect.
|
||||
/// </summary>
|
||||
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||
private Task? _watchTask;
|
||||
private HubConnection? _hub;
|
||||
|
||||
/// <summary>
|
||||
/// Service that provides gateway metric snapshots.
|
||||
/// </summary>
|
||||
/// <summary>Snapshot service used to seed the initial render before the hub connects.</summary>
|
||||
[Inject]
|
||||
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
|
||||
|
||||
/// <summary>Factory that builds the SignalR connection (mints the hub bearer token).</summary>
|
||||
[Inject]
|
||||
protected DashboardHubConnectionFactory HubFactory { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The most recent gateway metric snapshot, updated as it changes.
|
||||
/// The most recent gateway metric snapshot. Synchronously seeded from
|
||||
/// <see cref="IDashboardSnapshotService.GetSnapshot"/> for the very
|
||||
/// first render, then refreshed by hub push.
|
||||
/// </summary>
|
||||
protected DashboardSnapshot? Snapshot { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_watchTask = WatchSnapshotsAsync();
|
||||
Snapshot = SnapshotService.GetSnapshot();
|
||||
await ConnectHubAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches snapshot changes and triggers component refresh.
|
||||
/// </summary>
|
||||
private async Task WatchSnapshotsAsync()
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = HubFactory.Create("/hubs/snapshot");
|
||||
_hub.On<DashboardSnapshot>(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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,67 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="dashboard-shell">
|
||||
<header class="app-bar">
|
||||
<a class="brand" href="/"><span class="mark">▮</span> MXAccess Gateway</a>
|
||||
<nav class="app-nav">
|
||||
<NavLink href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink href="/sessions">Sessions</NavLink>
|
||||
<NavLink href="/workers">Workers</NavLink>
|
||||
<NavLink href="/events">Events</NavLink>
|
||||
<NavLink href="/galaxy">Galaxy</NavLink>
|
||||
<NavLink href="/browse">Browse</NavLink>
|
||||
<NavLink href="/alarms">Alarms</NavLink>
|
||||
<NavLink href="/apikeys">API Keys</NavLink>
|
||||
<NavLink href="/settings">Settings</NavLink>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="app-user">
|
||||
<span class="meta">@authState.User.Identity?.Name</span>
|
||||
<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="app-shell">
|
||||
<nav class="side-rail">
|
||||
<div class="rail-eyebrow">Overview</div>
|
||||
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
<button class="rail-btn" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</header>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-btn" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="page">
|
||||
@Body
|
||||
</main>
|
||||
|
||||
@@ -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<DashboardSnapshotHub>("/hubs/snapshot");
|
||||
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
|
||||
endpoints.MapHub<EventsHub>("/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
|
||||
|
||||
@@ -21,6 +21,9 @@ public static class DashboardServiceCollectionExtensions
|
||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||
services.AddSingleton<HubTokenService>();
|
||||
services.AddScoped<Hubs.DashboardHubConnectionFactory>();
|
||||
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
|
||||
services.AddHostedService<Hubs.AlarmsHubPublisher>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddAntiforgery();
|
||||
services.AddCascadingAuthenticationState();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub that pushes alarm-feed messages from the gateway's
|
||||
/// central alarm monitor. Connected clients auto-join
|
||||
/// <see cref="AllAlarmsGroup"/> on connect and receive every
|
||||
/// <c>AlarmFeedMessage</c> the monitor emits.
|
||||
/// </summary>
|
||||
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
|
||||
public sealed class AlarmsHub : Hub
|
||||
{
|
||||
public const string AllAlarmsGroup = "__alarms__";
|
||||
|
||||
/// <summary>Method name used to push <c>AlarmFeedMessage</c> values to clients.</summary>
|
||||
public const string AlarmMessage = "AlarmFeed";
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false);
|
||||
await base.OnConnectedAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that subscribes to
|
||||
/// <see cref="IGatewayAlarmService.StreamAsync"/> (no filter) and re-broadcasts
|
||||
/// every <see cref="AlarmFeedMessage"/> to every <see cref="AlarmsHub"/>
|
||||
/// client. The hub itself is session-less; clients filter / route messages
|
||||
/// in the browser.
|
||||
/// </summary>
|
||||
public sealed class AlarmsHubPublisher(
|
||||
IGatewayAlarmService alarmService,
|
||||
IHubContext<AlarmsHub> hubContext,
|
||||
ILogger<AlarmsHubPublisher> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side helper that builds a <see cref="HubConnection"/> targeted at a
|
||||
/// dashboard hub. Mints a fresh data-protected bearer token via
|
||||
/// <see cref="HubTokenService"/> on every (re)connect so the connection
|
||||
/// authenticates against <see cref="DashboardAuthenticationDefaults.HubAuthenticationScheme"/>
|
||||
/// without needing to forward the browser's HttpOnly cookie.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub that pushes a fresh <see cref="DashboardSnapshot"/> on every
|
||||
/// snapshot refresh. New connections receive the current snapshot
|
||||
/// immediately via <see cref="OnConnectedAsync"/>; subsequent refreshes are
|
||||
/// broadcast by <see cref="DashboardSnapshotPublisher"/>.
|
||||
/// </summary>
|
||||
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
|
||||
public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotService) : Hub
|
||||
{
|
||||
/// <summary>Method name used to push snapshot updates to clients.</summary>
|
||||
public const string SnapshotMessage = "SnapshotUpdated";
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false);
|
||||
await base.OnConnectedAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that subscribes to <see cref="IDashboardSnapshotService.WatchSnapshotsAsync"/>
|
||||
/// and broadcasts every snapshot it produces to every connected
|
||||
/// <see cref="DashboardSnapshotHub"/> client. There is one publisher per
|
||||
/// gateway process; clients listen via the hub.
|
||||
/// </summary>
|
||||
public sealed class DashboardSnapshotPublisher(
|
||||
IDashboardSnapshotService snapshotService,
|
||||
IHubContext<DashboardSnapshotHub> hubContext,
|
||||
ILogger<DashboardSnapshotPublisher> 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for per-session MxEvent push. Clients call
|
||||
/// <see cref="SubscribeSession"/> to join the group for a specific
|
||||
/// session; the dashboard's MxEvent broadcaster (a future hook on
|
||||
/// <c>EventStreamService</c>) sends messages to <c>session:{id}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
|
||||
public sealed class EventsHub : Hub
|
||||
{
|
||||
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<RouteEndpoint> endpoints = ((IEndpointRouteBuilder)app).DataSources
|
||||
.SelectMany(source => source.Endpoints)
|
||||
.OfType<RouteEndpoint>()
|
||||
.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<IEndpointNameMetadata>()?.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<HubTokenService>();
|
||||
Assert.NotNull(tokens);
|
||||
|
||||
using IServiceScope scope = app.Services.CreateScope();
|
||||
DashboardHubConnectionFactory factory = scope.ServiceProvider
|
||||
.GetRequiredService<DashboardHubConnectionFactory>();
|
||||
Assert.NotNull(factory);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user