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
_tab = "namespaces"'>Namespaces
_tab = "drivers"'>Drivers
_tab = "acls"'>ACLs
+ _tab = "redundancy"'>Redundancy
_tab = "audit"'>Audit
@@ -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
+
+
+
+
Secondary
+
@secondaries
+
+
+
+
+ @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'");
+ }
+}
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);
+ }
+}