@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 @implements IAsyncDisposable

Redundancy topology

@if (_roleChangedBanner is not null) {
@_roleChangedBanner
}

One row per ClusterNode in this cluster. Role, ApplicationUri, and ServiceLevelBase 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 RedundancyCoordinator apply-lease flow, not direct DB edits.

@if (_nodes is null) {

Loading…

} else if (_nodes.Count == 0) {
No ClusterNode rows for this cluster. The server process needs at least one entry (with a non-blank ApplicationUri) before it can start up per OPC UA spec.
} 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);
Nodes
@_nodes.Count
Primary
@primaries
Secondary
@secondaries
Stale
@staleCount
@if (primaries == 0 && standalone == 0) {
No Primary or Standalone node — the cluster has no authoritative write target. Secondaries stay read-only until one of them gets promoted via RedundancyCoordinator.
} else if (primaries > 1) {
Split-brain: @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.
} @foreach (var n in _nodes) { }
Node Role Host OPC UA port ServiceLevel base ApplicationUri Enabled Last seen
@n.NodeId @n.RedundancyRole @n.Host @n.OpcUaPort @n.ServiceLevelBase @n.ApplicationUri @if (n.Enabled) { Enabled } else { Disabled } @(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value)) @if (ClusterNodeService.IsStale(n)) { Stale }
} @code { [Parameter] public string ClusterId { get; set; } = string.Empty; private List? _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 = new HubConnectionBuilder() .WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status")) .WithAutomaticReconnect() .Build(); _hub.On("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); }); await _hub.StartAsync(); await _hub.SendAsync("SubscribeCluster", ClusterId); } 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 => "bg-success", RedundancyRole.Secondary => "bg-info", RedundancyRole.Standalone => "bg-primary", _ => "bg-secondary", }; 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'"); } }