@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);
@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.
}
| Node |
Role |
Host |
OPC UA port |
ServiceLevel base |
ApplicationUri |
Enabled |
Last seen |
@foreach (var n in _nodes)
{
@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'");
}
}