Compare commits
4 Commits
equipment-
...
diff-viewe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a38bc57b | ||
| cecb84fa5d | |||
|
|
13d5a7968b | ||
| d1686ed82d |
@@ -52,6 +52,7 @@ else
|
||||
<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>
|
||||
|
||||
@@ -92,6 +93,10 @@ else
|
||||
{
|
||||
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "redundancy")
|
||||
{
|
||||
<RedundancyTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "audit")
|
||||
{
|
||||
<AuditTab ClusterId="@ClusterId"/>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
@* Per-section diff renderer — the base used by DiffViewer for every known TableName. Caps
|
||||
output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the
|
||||
Blazor render; overflow banner tells operator how many rows were hidden. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>@Title</strong>
|
||||
<small class="text-muted ms-2">@Description</small>
|
||||
</div>
|
||||
<div>
|
||||
@if (_added > 0) { <span class="badge bg-success me-1">+@_added</span> }
|
||||
@if (_removed > 0) { <span class="badge bg-danger me-1">−@_removed</span> }
|
||||
@if (_modified > 0) { <span class="badge bg-warning text-dark me-1">~@_modified</span> }
|
||||
@if (_total == 0) { <span class="badge bg-secondary">no changes</span> }
|
||||
</div>
|
||||
</div>
|
||||
@if (_total == 0)
|
||||
{
|
||||
<div class="card-body text-muted small">No changes in this section.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_total > RowCap)
|
||||
{
|
||||
<div class="alert alert-warning mb-0 small rounded-0">
|
||||
Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class
|
||||
diffs. Inspect the remainder via the SQL <code>sp_ComputeGenerationDiff</code> directly.
|
||||
</div>
|
||||
}
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _visibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>
|
||||
public const int DefaultRowCap = 1000;
|
||||
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter, EditorRequired] public IReadOnlyList<DiffRow> Rows { get; set; } = [];
|
||||
[Parameter] public int RowCap { get; set; } = DefaultRowCap;
|
||||
|
||||
private int _total;
|
||||
private int _added;
|
||||
private int _removed;
|
||||
private int _modified;
|
||||
private List<DiffRow> _visibleRows = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_total = Rows.Count;
|
||||
_added = 0; _removed = 0; _modified = 0;
|
||||
foreach (var r in Rows)
|
||||
{
|
||||
switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": _added++; break;
|
||||
case "Removed": _removed++; break;
|
||||
case "Modified": _modified++; break;
|
||||
}
|
||||
}
|
||||
_visibleRows = _total > RowCap ? Rows.Take(RowCap).ToList() : Rows.ToList();
|
||||
}
|
||||
}
|
||||
@@ -28,36 +28,44 @@ else if (_rows.Count == 0)
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover table-sm">
|
||||
<thead><tr><th>Table</th><th>LogicalId</th><th>ChangeKind</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.TableName</td>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small text-muted mb-3">
|
||||
@_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections.
|
||||
Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts.
|
||||
</p>
|
||||
|
||||
@foreach (var sec in Sections)
|
||||
{
|
||||
<DiffSection Title="@sec.Title"
|
||||
Description="@sec.Description"
|
||||
Rows="@RowsFor(sec.TableName)"/>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered section definitions — each maps a <c>TableName</c> emitted by
|
||||
/// <c>sp_ComputeGenerationDiff</c> to a human label + description. The proc currently
|
||||
/// emits Namespace/DriverInstance/Equipment/Tag; UnsLine + NodeAcl entries render as
|
||||
/// empty "no changes" cards until the proc is extended (tracked in tasks #196 + #156
|
||||
/// follow-up). Six sections total matches the task #156 target.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyList<SectionDef> Sections = new[]
|
||||
{
|
||||
new SectionDef("Namespace", "Namespaces", "OPC UA namespace URIs + enablement"),
|
||||
new SectionDef("DriverInstance", "Driver instances","Per-cluster driver configuration rows"),
|
||||
new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"),
|
||||
new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"),
|
||||
new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"),
|
||||
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (proc-extension pending)"),
|
||||
};
|
||||
|
||||
private List<DiffRow>? _rows;
|
||||
private string _fromLabel = "(empty)";
|
||||
private string? _error;
|
||||
private int _sectionsWithChanges;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -67,7 +75,13 @@ else
|
||||
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
||||
_sectionsWithChanges = Sections.Count(s => _rows.Any(r => r.TableName == s.TableName));
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private IReadOnlyList<DiffRow> RowsFor(string tableName) =>
|
||||
_rows?.Where(r => r.TableName == tableName).ToList() ?? [];
|
||||
|
||||
private sealed record SectionDef(string TableName, string Title, string Description);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
<h4>Redundancy topology</h4>
|
||||
<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;
|
||||
|
||||
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'");
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<HostStatusService>();
|
||||
builder.Services.AddScoped<ClusterNodeService>();
|
||||
builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||
|
||||
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs
Normal file
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterNodeService.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>Stale-threshold matching <c>HostStatusService.StaleThreshold</c> — 30s of clock
|
||||
/// tolerance covers a missed heartbeat plus publisher GC pauses.</summary>
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
public Task<List<ClusterNode>> 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;
|
||||
}
|
||||
@@ -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<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user