d692232191
EventsHub publisher (closes the v2.1 follow-up flagged in the previous commit)
EventStreamService now mirrors every MxEvent it forwards to a gRPC client
into the `EventsHub` group for the session. The fan-out goes through a new
singleton `IDashboardEventBroadcaster`:
* IDashboardEventBroadcaster — abstraction so EventStreamService doesn't
take a direct dependency on SignalR.
* DashboardEventBroadcaster — singleton implementation that hands the
SendAsync to IHubContext<EventsHub> as fire-and-forget. Errors are
logged at debug and dropped so the source gRPC stream is never
blocked.
EventStreamService now takes IDashboardEventBroadcaster as a ctor parameter
and calls Publish(sessionId, publicEvent) once per event after sequence
filtering, before the bounded queue write. Test fixtures and the live
integration harness pass NullDashboardEventBroadcaster.Instance so the
broadcaster is a no-op in unit tests.
SessionDetailsPage adds a "Recent events" panel:
* implements IAsyncDisposable
* opens a second HubConnection via DashboardHubConnectionFactory targeting
/hubs/events
* calls SubscribeSession(SessionId) on Start
* renders the most recent 50 events in a small table (worker seq, family,
server/item handle, alarm reference when the event is OnAlarmTransition)
* shows a live/offline conn-pill driven by HubConnection.Closed /
Reconnected events
The dashboard mirror is intentionally passive — events appear only while a
gRPC client is also consuming that session's events. Documented as such in
the empty-state copy and in GatewayDashboardDesign.md.
Documentation refresh
Every doc that referenced the retired options (PathBase, RequireAdminScope,
RequiredGroup) and the old API-key-cookie auth flow is updated to describe
the new model:
* CLAUDE.md — Authentication section now explains LDAP bind +
GroupToRole + HubToken bearer flow.
* gateway.md — Dashboard section: root-mounted routes, snapshot/alarms/
events SignalR hubs, LDAP cookie + bearer scheme.
* docs/GatewayConfiguration.md — drop PathBase / RequireAdminScope rows,
add GroupToRole row, append "Authorization policies" and "SignalR hubs"
subsections describing the three policies and the /hubs/* endpoints.
* docs/GatewayDashboardDesign.md — hosting model (root mount, new
endpoint layout), Realtime Updates rewritten as a hub table
(DashboardSnapshotHub / AlarmsHub / EventsHub with producers, payloads,
and routing), Authentication And Authorization rewritten around LDAP +
role mapping + the hub bearer flow, Configuration block updated.
* docs/GatewayProcessDesign.md — security-section dashboard paragraph
and the example config block both refreshed to LDAP/role auth.
* docs/ImplementationPlanGateway.md — dashboard-auth deliverable list
updated (LDAP bind + GroupToRole + /hubs/token bearer mint replace the
API-key login flow).
* docs/GatewayTesting.md — DashboardLdapLiveTests blurb describes the
GroupToRole fixture (`{ GwAdmin: Admin }`) instead of the retired
RequiredGroup default; success-path assertion explains the role-claim
check.
Verification: 475 server tests, 275 worker tests (+ 9 dev-rig skips), 18
integration tests (live MxAccess + LDAP + Galaxy) all pass — including the
live worker smoke test fixture that now constructs EventStreamService with
the new broadcaster parameter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
7.6 KiB
Plaintext
213 lines
7.6 KiB
Plaintext
@page "/sessions/{SessionId}"
|
|
@inherits DashboardPageBase
|
|
@implements IAsyncDisposable
|
|
@using Microsoft.AspNetCore.SignalR.Client
|
|
@using ZB.MOM.WW.MxGateway.Contracts.Proto
|
|
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
|
|
|
|
<PageTitle>Dashboard Session</PageTitle>
|
|
|
|
@if (Snapshot is null)
|
|
{
|
|
<div class="empty-state">Loading session.</div>
|
|
}
|
|
else if (CurrentSession is null)
|
|
{
|
|
<section class="dashboard-section">
|
|
<h1 class="h4 mb-3">Session Not Found</h1>
|
|
<p class="mb-0">The session is not present in the current snapshot.</p>
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
<div class="dashboard-page-header">
|
|
<div>
|
|
<h1>Session Details</h1>
|
|
<div class="text-secondary"><code>@CurrentSession.SessionId</code></div>
|
|
</div>
|
|
<StatusBadge Text="@CurrentSession.State.ToString()" />
|
|
</div>
|
|
|
|
<section class="dashboard-section">
|
|
<div class="section-heading">
|
|
<h2>Session</h2>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm dashboard-table details-table">
|
|
<tbody>
|
|
<tr><th scope="row">Backend</th><td>@CurrentSession.BackendName</td></tr>
|
|
<tr><th scope="row">Client identity</th><td>@DashboardDisplay.Text(CurrentSession.ClientIdentity)</td></tr>
|
|
<tr><th scope="row">Client session</th><td>@DashboardDisplay.Text(CurrentSession.ClientSessionName)</td></tr>
|
|
<tr><th scope="row">Client correlation</th><td>@DashboardDisplay.Text(CurrentSession.ClientCorrelationId)</td></tr>
|
|
<tr><th scope="row">Opened</th><td>@DashboardDisplay.DateTime(CurrentSession.OpenedAt)</td></tr>
|
|
<tr><th scope="row">Last activity</th><td>@DashboardDisplay.DateTime(CurrentSession.LastClientActivityAt)</td></tr>
|
|
<tr><th scope="row">Lease expires</th><td>@DashboardDisplay.DateTime(CurrentSession.LeaseExpiresAt)</td></tr>
|
|
<tr><th scope="row">Events received</th><td>@DashboardDisplay.Count(CurrentSession.EventsReceived)</td></tr>
|
|
<tr><th scope="row">Last fault</th><td>@DashboardDisplay.Text(CurrentSession.LastFault)</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="dashboard-section">
|
|
<div class="section-heading">
|
|
<h2>Worker</h2>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm dashboard-table details-table">
|
|
<tbody>
|
|
<tr><th scope="row">Process id</th><td>@(CurrentSession.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td></tr>
|
|
<tr><th scope="row">State</th><td><StatusBadge Text="@(CurrentSession.WorkerState?.ToString() ?? "-")" /></td></tr>
|
|
<tr><th scope="row">Last heartbeat</th><td>@DashboardDisplay.DateTime(CurrentSession.LastWorkerHeartbeatAt)</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="dashboard-section">
|
|
<div class="section-heading">
|
|
<h2>Recent events</h2>
|
|
<span class="conn-pill" data-state="@(_eventsConnected ? "connected" : "disconnected")">
|
|
<span class="dot"></span>
|
|
<span>@(_eventsConnected ? "live" : "offline")</span>
|
|
</span>
|
|
</div>
|
|
@if (_recentEvents.Count == 0)
|
|
{
|
|
<div class="empty-state">
|
|
Waiting for events. The dashboard mirrors the session's gRPC event stream — events
|
|
appear here only while a gRPC client is also consuming this session's events.
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-sm dashboard-table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Worker seq</th>
|
|
<th scope="col">Family</th>
|
|
<th scope="col">Server</th>
|
|
<th scope="col">Item</th>
|
|
<th scope="col">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (MxEvent evt in _recentEvents)
|
|
{
|
|
<tr>
|
|
<td class="num mono">@evt.WorkerSequence</td>
|
|
<td>@evt.Family</td>
|
|
<td class="num mono">@evt.ServerHandle</td>
|
|
<td class="num mono">@evt.ItemHandle</td>
|
|
<td>@DashboardDisplay.Text(EventStatusLabel(evt))</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
private const int MaxRecentEvents = 50;
|
|
|
|
[Parameter]
|
|
public string SessionId { get; set; } = string.Empty;
|
|
|
|
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
|
|
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
|
|
|
|
private HubConnection? _eventsHub;
|
|
private bool _eventsConnected;
|
|
private string? _subscribedSessionId;
|
|
private readonly LinkedList<MxEvent> _recentEvents = new();
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
|
|
{
|
|
await DetachEventsHubAsync().ConfigureAwait(false);
|
|
await AttachEventsHubAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task AttachEventsHubAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(SessionId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_eventsHub = HubFactory.Create("/hubs/events");
|
|
_eventsHub.On<MxEvent>(EventsHub.EventMessage, async mxEvent =>
|
|
{
|
|
_recentEvents.AddFirst(mxEvent);
|
|
while (_recentEvents.Count > MaxRecentEvents)
|
|
{
|
|
_recentEvents.RemoveLast();
|
|
}
|
|
|
|
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
|
});
|
|
|
|
_eventsHub.Closed += _ =>
|
|
{
|
|
_eventsConnected = false;
|
|
return InvokeAsync(StateHasChanged);
|
|
};
|
|
_eventsHub.Reconnected += _ =>
|
|
{
|
|
_eventsConnected = true;
|
|
return InvokeAsync(StateHasChanged);
|
|
};
|
|
|
|
try
|
|
{
|
|
await _eventsHub.StartAsync().ConfigureAwait(false);
|
|
await _eventsHub.SendAsync("SubscribeSession", SessionId).ConfigureAwait(false);
|
|
_eventsConnected = true;
|
|
_subscribedSessionId = SessionId;
|
|
}
|
|
catch
|
|
{
|
|
_eventsConnected = false;
|
|
}
|
|
}
|
|
|
|
private async Task DetachEventsHubAsync()
|
|
{
|
|
HubConnection? hub = _eventsHub;
|
|
_eventsHub = null;
|
|
_eventsConnected = false;
|
|
_subscribedSessionId = null;
|
|
_recentEvents.Clear();
|
|
|
|
if (hub is not null)
|
|
{
|
|
try
|
|
{
|
|
await hub.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// Disposal-time errors are best-effort.
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string EventStatusLabel(MxEvent evt)
|
|
{
|
|
return evt.Family == MxEventFamily.OnAlarmTransition
|
|
? evt.OnAlarmTransition?.AlarmFullReference ?? string.Empty
|
|
: string.Empty;
|
|
}
|
|
|
|
public new async ValueTask DisposeAsync()
|
|
{
|
|
await DetachEventsHubAsync().ConfigureAwait(false);
|
|
await base.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|