@using ZB.MOM.WW.OtOpcUa.Admin.Services @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @inject ClusterNodeService NodeSvc

Redundancy topology

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; protected override async Task OnParametersSetAsync() { _nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None); } 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'"); } }