diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor index 781af6d..72cd9ba 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor @@ -52,6 +52,7 @@ else + @@ -92,6 +93,10 @@ else { } + else if (_tab == "redundancy") + { + + } else if (_tab == "audit") { diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor new file mode 100644 index 0000000..068f059 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/RedundancyTab.razor @@ -0,0 +1,136 @@ +@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) + { + + + + + + + + + + + } + +
NodeRoleHostOPC UA portServiceLevel baseApplicationUriEnabledLast 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'"); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index ef3d3e7..a0fe448 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -48,6 +48,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs new file mode 100644 index 0000000..1729a54 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Read-side service for ClusterNode rows + their cluster-scoped redundancy view. Consumed +/// by the RedundancyTab on the cluster detail page. Writes (role swap, node enable/disable) +/// are not supported here — role swap happens through the RedundancyCoordinator apply-lease +/// flow on the server side and would conflict with any direct DB mutation from Admin. +/// +public sealed class ClusterNodeService(OtOpcUaConfigDbContext db) +{ + /// Stale-threshold matching HostStatusService.StaleThreshold — 30s of clock + /// tolerance covers a missed heartbeat plus publisher GC pauses. + public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30); + + public Task> ListByClusterAsync(string clusterId, CancellationToken ct) => + db.ClusterNodes.AsNoTracking() + .Where(n => n.ClusterId == clusterId) + .OrderByDescending(n => n.ServiceLevelBase) + .ThenBy(n => n.NodeId) + .ToListAsync(ct); + + public static bool IsStale(ClusterNode node) => + node.LastSeenAt is null || DateTime.UtcNow - node.LastSeenAt.Value > StaleThreshold; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ClusterNodeServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ClusterNodeServiceTests.cs new file mode 100644 index 0000000..ad3f72b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ClusterNodeServiceTests.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class ClusterNodeServiceTests +{ + [Fact] + public void IsStale_NullLastSeen_Returns_True() + { + var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: null); + ClusterNodeService.IsStale(node).ShouldBeTrue(); + } + + [Fact] + public void IsStale_RecentLastSeen_Returns_False() + { + var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: DateTime.UtcNow.AddSeconds(-5)); + ClusterNodeService.IsStale(node).ShouldBeFalse(); + } + + [Fact] + public void IsStale_Old_LastSeen_Returns_True() + { + var node = NewNode("A", RedundancyRole.Primary, + lastSeenAt: DateTime.UtcNow - ClusterNodeService.StaleThreshold - TimeSpan.FromSeconds(1)); + ClusterNodeService.IsStale(node).ShouldBeTrue(); + } + + [Fact] + public async Task ListByClusterAsync_OrdersByServiceLevelBase_Descending_Then_NodeId() + { + using var ctx = NewContext(); + ctx.ClusterNodes.AddRange( + NewNode("B-low", RedundancyRole.Secondary, serviceLevelBase: 150, clusterId: "c1"), + NewNode("A-high", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c1"), + NewNode("other-cluster", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c2")); + await ctx.SaveChangesAsync(); + + var svc = new ClusterNodeService(ctx); + var rows = await svc.ListByClusterAsync("c1", CancellationToken.None); + + rows.Count.ShouldBe(2); + rows[0].NodeId.ShouldBe("A-high"); // higher ServiceLevelBase first + rows[1].NodeId.ShouldBe("B-low"); + } + + private static ClusterNode NewNode( + string nodeId, + RedundancyRole role, + DateTime? lastSeenAt = null, + int serviceLevelBase = 200, + string clusterId = "c1") => new() + { + NodeId = nodeId, + ClusterId = clusterId, + RedundancyRole = role, + Host = $"{nodeId}.example", + ApplicationUri = $"urn:{nodeId}", + ServiceLevelBase = (byte)serviceLevelBase, + LastSeenAt = lastSeenAt, + CreatedBy = "test", + }; + + private static OtOpcUaConfigDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new OtOpcUaConfigDbContext(opts); + } +}