The Admin-003 fix gated every SignalR hub with [Authorize], but the server-side
Blazor HubConnection clients had no way to authenticate: the browser's HttpOnly
auth cookie is not reachable from the interactive circuit, so every hub negotiate
returned 401 and the Admin live-update feature was non-functional app-wide
(silently degraded on Hosts/ScriptLog, fatal on the cluster pages).
Introduce a token-based hub auth path:
- HubTokenService mints/validates short-lived tokens using ASP.NET Core Data
Protection (the same primitive that protects the auth cookie — no signing-key
management, no new packages). Tokens carry the user's name + roles.
- HubTokenAuthenticationHandler is a custom "HubToken" auth scheme that reads the
token from the Authorization: Bearer header (negotiate) or the access_token
query parameter (WebSocket upgrade).
- The "HubClients" authorization policy runs both the cookie and HubToken
schemes; the hub endpoints use RequireAuthorization("HubClients").
- AdminHubConnectionFactory builds hub connections with an AccessTokenProvider
that mints a fresh token for the circuit's authenticated user on every
(re)connect. All six hub-consuming pages now resolve connections through it.
Hub negotiate now returns 200 and the WebSocket upgrades (101); live updates
work. The best-effort try/catch guards added previously are kept as defence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
7.4 KiB
Plaintext
188 lines
7.4 KiB
Plaintext
@using Microsoft.AspNetCore.SignalR.Client
|
|
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
|
@inject ClusterNodeService NodeSvc
|
|
@inject NavigationManager Nav
|
|
@inject AdminHubConnectionFactory HubFactory
|
|
@implements IAsyncDisposable
|
|
|
|
<h4 class="panel-head">Redundancy topology</h4>
|
|
@if (_roleChangedBanner is not null)
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.02s">@_roleChangedBanner</section>
|
|
}
|
|
<p class="text-muted small">
|
|
One row per <span class="mono">ClusterNode</span> in this cluster. Role, <span class="mono">ApplicationUri</span>,
|
|
and <span class="mono">ServiceLevelBase</span> are authored separately; the Admin UI shows them read-only
|
|
here so operators can confirm the published topology without touching it. LastSeen older than
|
|
@((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has
|
|
stopped heart-beating and is likely down. Role swap goes through the server-side
|
|
<span class="mono">RedundancyCoordinator</span> apply-lease flow, not direct DB edits.
|
|
</p>
|
|
|
|
@if (_nodes is null)
|
|
{
|
|
<p>Loading…</p>
|
|
}
|
|
else if (_nodes.Count == 0)
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.02s">
|
|
No ClusterNode rows for this cluster. The server process needs at least one entry
|
|
(with a non-blank <span class="mono">ApplicationUri</span>) before it can start up per OPC UA spec.
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
var primaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary);
|
|
var secondaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Secondary);
|
|
var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone);
|
|
var staleCount = _nodes.Count(ClusterNodeService.IsStale);
|
|
|
|
<section class="agg-grid rise" style="animation-delay:.02s">
|
|
<div class="agg-card">
|
|
<div class="agg-label">Nodes</div>
|
|
<div class="agg-value numeric">@_nodes.Count</div>
|
|
</div>
|
|
<div class="agg-card">
|
|
<div class="agg-label">Primary</div>
|
|
<div class="agg-value numeric @(primaries > 0 ? "s-ok" : "")">@primaries</div>
|
|
</div>
|
|
<div class="agg-card">
|
|
<div class="agg-label">Secondary</div>
|
|
<div class="agg-value numeric">@secondaries</div>
|
|
</div>
|
|
<div class="agg-card">
|
|
<div class="agg-label">Stale</div>
|
|
<div class="agg-value numeric @(staleCount > 0 ? "s-warn" : "")">@staleCount</div>
|
|
</div>
|
|
</section>
|
|
|
|
@if (primaries == 0 && standalone == 0)
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.08s">
|
|
<span class="s-bad">No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
|
|
stay read-only until one of them gets promoted via <span class="mono">RedundancyCoordinator</span>.</span>
|
|
</section>
|
|
}
|
|
else if (primaries > 1)
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.08s">
|
|
<span class="s-bad"><strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
|
|
enforcement should have made this impossible at the coordinator level. Investigate
|
|
immediately — one of the rows was likely hand-edited.</span>
|
|
</section>
|
|
}
|
|
|
|
<section class="panel rise" style="animation-delay:.14s">
|
|
<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Node</th>
|
|
<th>Role</th>
|
|
<th>Host</th>
|
|
<th class="num">OPC UA port</th>
|
|
<th class="num">ServiceLevel base</th>
|
|
<th>ApplicationUri</th>
|
|
<th>Enabled</th>
|
|
<th>Last seen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var n in _nodes)
|
|
{
|
|
<tr>
|
|
<td><span class="mono">@n.NodeId</span></td>
|
|
<td><span class="chip @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
|
|
<td>@n.Host</td>
|
|
<td class="num mono">@n.OpcUaPort</td>
|
|
<td class="num">@n.ServiceLevelBase</td>
|
|
<td class="mono">@n.ApplicationUri</td>
|
|
<td>
|
|
@if (n.Enabled) { <span class="chip chip-ok">Enabled</span> }
|
|
else { <span class="chip chip-idle">Disabled</span> }
|
|
</td>
|
|
<td class="@(ClusterNodeService.IsStale(n) ? "s-warn" : "")">
|
|
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
|
|
@if (ClusterNodeService.IsStale(n)) { <span class="chip chip-warn ms-1">Stale</span> }
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
|
|
|
private List<ClusterNode>? _nodes;
|
|
private HubConnection? _hub;
|
|
private string? _roleChangedBanner;
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
|
if (_hub is null) await ConnectHubAsync();
|
|
}
|
|
|
|
private async Task ConnectHubAsync()
|
|
{
|
|
_hub = HubFactory.Create("/hubs/fleet");
|
|
|
|
_hub.On<RoleChangedMessage>("RoleChanged", async msg =>
|
|
{
|
|
if (msg.ClusterId != ClusterId) return;
|
|
_roleChangedBanner = $"Role changed on {msg.NodeId}: {msg.FromRole} → {msg.ToRole} at {msg.ObservedAtUtc:HH:mm:ss 'UTC'}";
|
|
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
|
await InvokeAsync(StateHasChanged);
|
|
});
|
|
|
|
// Best-effort: FleetStatusHub requires an authenticated caller, and the server-side
|
|
// HubConnection cannot forward the browser auth cookie — swallow connect failures so
|
|
// the tab still renders. Live role-change updates degrade.
|
|
try
|
|
{
|
|
await _hub.StartAsync();
|
|
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
|
}
|
|
catch
|
|
{
|
|
// best-effort live updates — see comment above
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_hub is not null)
|
|
{
|
|
await _hub.DisposeAsync();
|
|
_hub = null;
|
|
}
|
|
}
|
|
|
|
private static string RowClass(ClusterNode n) =>
|
|
ClusterNodeService.IsStale(n) ? "table-warning" :
|
|
!n.Enabled ? "table-secondary" : "";
|
|
|
|
private static string RoleBadge(RedundancyRole r) => r switch
|
|
{
|
|
RedundancyRole.Primary => "chip-ok",
|
|
RedundancyRole.Secondary => "chip-idle",
|
|
RedundancyRole.Standalone => "chip-idle",
|
|
_ => "chip-idle",
|
|
};
|
|
|
|
private static string FormatAge(DateTime t)
|
|
{
|
|
var age = DateTime.UtcNow - t;
|
|
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
|
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
|
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
|
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
|
}
|
|
}
|