Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor
Joseph Doherty 13d5a7968b Admin RedundancyTab — per-cluster read-only topology view. Closes the UI slice of task #149 (Phase 6.3 Stream E — Admin UI RedundancyTab + OpenTelemetry metrics + SignalR); the OpenTelemetry metrics + RoleChanged SignalR push are split into new follow-up task #198 because each is a structural add that deserves its own test matrix + NuGet-dep decision rather than riding this UI PR. New /clusters/{ClusterId} Redundancy tab slotted between ACLs and Audit in the existing ClusterDetail tab bar. Shows each ClusterNode row in the cluster with columns Node / Role (Primary green, Secondary blue, Standalone primary-blue badge) / Host / OPC UA port / ServiceLevel base / ApplicationUri (text-break so the long urn: doesn't blow out the table) / Enabled badge / Last seen (relative age via the same FormatAge helper as Hosts.razor, with a yellow "Stale" chip once LastSeenAt crosses the 30s threshold shared with HostStatusService.StaleThreshold — a missed heartbeat plus clock-skew buffer). Four summary cards above the table — total Nodes, Primary count, Secondary count, Stale count. Two guard-rail alerts: (a) red "No Primary or Standalone" when the cluster has no authoritative write target (all rows are Secondaries — read-only until one is promoted by the server-side RedundancyCoordinator apply-lease flow); (b) red "Split-brain" when >1 Primary exists — apply-lease enforcement at the coordinator level should have made this impossible, so the alert implies a hand-edited DB row + an investigation. New ClusterNodeService with ListByClusterAsync (ordered by ServiceLevelBase descending so Primary rows with higher base float to the top) + a static IsStale predicate matching HostStatusService's 30s convention. DI-registered alongside the existing scoped services in Program.cs. Writes (role swap, enable/disable) are deliberately absent from the service — they go through the RedundancyCoordinator apply-lease flow on the server side + direct DB mutation from Admin would race with it. New ClusterNodeServiceTests covering IsStale across null/recent/old LastSeenAt + ListByClusterAsync ordering + cluster filter. 4/4 new tests passing; full Admin.Tests suite 76/76 (was 72 before this PR, +4). Admin project builds 0 errors. Task #198 captures the deferred work: (1) OpenTelemetry Meter for primary/secondary/stale counts + role_transition counter with from/to/node tags + OTLP exporter config; (2) RoleChanged SignalR push — extend FleetStatusPoller to detect RedundancyRole changes on ClusterNode rows + emit a RoleChanged hub message so the RedundancyTab refreshes instantly instead of on-page-load polling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:14:25 -04:00

171 lines
6.7 KiB
Plaintext

@page "/clusters/{ClusterId}"
@using Microsoft.AspNetCore.Components.Web
@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
@implements IAsyncDisposable
@rendermode RenderMode.InteractiveServer
@inject ClusterService ClusterSvc
@inject GenerationService GenerationSvc
@inject NavigationManager Nav
@if (_cluster is null)
{
<p>Loading…</p>
}
else
{
@if (_liveBanner is not null)
{
<div class="alert alert-info py-2 small">
<strong>Live update:</strong> @_liveBanner
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
</div>
}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="mb-0">@_cluster.Name</h1>
<code class="text-muted">@_cluster.ClusterId</code>
@if (!_cluster.Enabled) { <span class="badge bg-secondary ms-2">Disabled</span> }
</div>
<div>
@if (_currentDraft is not null)
{
<a href="/clusters/@ClusterId/draft/@_currentDraft.GenerationId" class="btn btn-outline-primary">
Edit current draft (gen @_currentDraft.GenerationId)
</a>
}
else
{
<button class="btn btn-primary" @onclick="CreateDraftAsync" disabled="@_busy">New draft</button>
}
</div>
</div>
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><button class="nav-link @Tab("overview")" @onclick='() => _tab = "overview"'>Overview</button></li>
<li class="nav-item"><button class="nav-link @Tab("generations")" @onclick='() => _tab = "generations"'>Generations</button></li>
<li class="nav-item"><button class="nav-link @Tab("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
<li class="nav-item"><button class="nav-link @Tab("uns")" @onclick='() => _tab = "uns"'>UNS Structure</button></li>
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
<li class="nav-item"><button class="nav-link @Tab("redundancy")" @onclick='() => _tab = "redundancy"'>Redundancy</button></li>
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
</ul>
@if (_tab == "overview")
{
<dl class="row">
<dt class="col-sm-3">Enterprise / Site</dt><dd class="col-sm-9">@_cluster.Enterprise / @_cluster.Site</dd>
<dt class="col-sm-3">Redundancy</dt><dd class="col-sm-9">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</dd>
<dt class="col-sm-3">Current published</dt>
<dd class="col-sm-9">
@if (_currentPublished is not null) { <span>@_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u"))</span> }
else { <span class="text-muted">none published yet</span> }
</dd>
<dt class="col-sm-3">Created</dt><dd class="col-sm-9">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</dd>
</dl>
}
else if (_tab == "generations")
{
<Generations ClusterId="@ClusterId"/>
}
else if (_tab == "equipment" && _currentDraft is not null)
{
<EquipmentTab GenerationId="@_currentDraft.GenerationId"/>
}
else if (_tab == "uns" && _currentDraft is not null)
{
<UnsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "namespaces" && _currentDraft is not null)
{
<NamespacesTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "drivers" && _currentDraft is not null)
{
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "acls" && _currentDraft is not null)
{
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "redundancy")
{
<RedundancyTab ClusterId="@ClusterId"/>
}
else if (_tab == "audit")
{
<AuditTab ClusterId="@ClusterId"/>
}
else
{
<p class="text-muted">Open a draft to edit this cluster's content.</p>
}
}
@code {
[Parameter] public string ClusterId { get; set; } = string.Empty;
private ServerCluster? _cluster;
private ConfigGeneration? _currentDraft;
private ConfigGeneration? _currentPublished;
private string _tab = "overview";
private bool _busy;
private HubConnection? _hub;
private string? _liveBanner;
private string Tab(string key) => _tab == key ? "active" : string.Empty;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
await ConnectHubAsync();
}
private async Task LoadAsync()
{
_cluster = await ClusterSvc.FindAsync(ClusterId, CancellationToken.None);
var gens = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
_currentDraft = gens.FirstOrDefault(g => g.Status == GenerationStatus.Draft);
_currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published);
}
private async Task ConnectHubAsync()
{
_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
.WithAutomaticReconnect()
.Build();
_hub.On<NodeStateChangedMessage>("NodeStateChanged", async msg =>
{
if (msg.ClusterId != ClusterId) return;
_liveBanner = $"Node {msg.NodeId}: {msg.LastAppliedStatus ?? "seen"} at {msg.LastAppliedAt?.ToString("u") ?? msg.LastSeenAt?.ToString("u") ?? "-"}";
await LoadAsync();
await InvokeAsync(StateHasChanged);
});
await _hub.StartAsync();
await _hub.SendAsync("SubscribeCluster", ClusterId);
}
private async Task CreateDraftAsync()
{
_busy = true;
try
{
var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: "admin-ui", CancellationToken.None);
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}");
}
finally { _busy = false; }
}
public async ValueTask DisposeAsync()
{
if (_hub is not null) await _hub.DisposeAsync();
}
}