chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
@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
|
||||
@inject ClusterNodeService NodeSvc
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h4>Redundancy topology</h4>
|
||||
@if (_roleChangedBanner is not null)
|
||||
{
|
||||
<div class="alert alert-info small mb-2">@_roleChangedBanner</div>
|
||||
}
|
||||
<p class="text-muted small">
|
||||
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>,
|
||||
and <code>ServiceLevelBase</code> 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
|
||||
<code>RedundancyCoordinator</code> apply-lease flow, not direct DB edits.
|
||||
</p>
|
||||
|
||||
@if (_nodes is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_nodes.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No ClusterNode rows for this cluster. The server process needs at least one entry
|
||||
(with a non-blank <code>ApplicationUri</code>) before it can start up per OPC UA spec.
|
||||
</div>
|
||||
}
|
||||
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);
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_nodes.Count</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Primary</h6>
|
||||
<div class="fs-3 text-success">@primaries</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-info"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Secondary</h6>
|
||||
<div class="fs-3 text-info">@secondaries</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card @(staleCount > 0 ? "border-warning" : "")"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 @(staleCount > 0 ? "text-warning" : "")">@staleCount</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (primaries == 0 && standalone == 0)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
|
||||
stay read-only until one of them gets promoted via <code>RedundancyCoordinator</code>.
|
||||
</div>
|
||||
}
|
||||
else if (primaries > 1)
|
||||
{
|
||||
<div class="alert alert-danger small mb-3">
|
||||
<strong>Split-brain:</strong> @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.
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Role</th>
|
||||
<th>Host</th>
|
||||
<th class="text-end">OPC UA port</th>
|
||||
<th class="text-end">ServiceLevel base</th>
|
||||
<th>ApplicationUri</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var n in _nodes)
|
||||
{
|
||||
<tr class="@RowClass(n)">
|
||||
<td><code>@n.NodeId</code></td>
|
||||
<td><span class="badge @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
|
||||
<td>@n.Host</td>
|
||||
<td class="text-end"><code>@n.OpcUaPort</code></td>
|
||||
<td class="text-end">@n.ServiceLevelBase</td>
|
||||
<td class="small text-break"><code>@n.ApplicationUri</code></td>
|
||||
<td>
|
||||
@if (n.Enabled) { <span class="badge bg-success">Enabled</span> }
|
||||
else { <span class="badge bg-secondary">Disabled</span> }
|
||||
</td>
|
||||
<td class="small @(ClusterNodeService.IsStale(n) ? "text-warning fw-bold" : "")">
|
||||
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
|
||||
@if (ClusterNodeService.IsStale(n)) { <span class="badge bg-warning text-dark ms-1">Stale</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<ClusterNode>? _nodes;
|
||||
private HubConnection? _hub;
|
||||
private string? _roleChangedBanner;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
||||
if (_hub is null) await ConnectHubAsync();
|
||||
}
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hub.On<RoleChangedMessage>("RoleChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId) return;
|
||||
_roleChangedBanner = $"Role changed on {msg.NodeId}: {msg.FromRole} → {msg.ToRole} at {msg.ObservedAtUtc:HH:mm:ss 'UTC'}";
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null)
|
||||
{
|
||||
await _hub.DisposeAsync();
|
||||
_hub = null;
|
||||
}
|
||||
}
|
||||
|
||||
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'");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user