Compare commits
5 Commits
identifica
...
redundancy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13d5a7968b | ||
|
|
ac69a1c39d | ||
| 30714831fa | |||
|
|
44d4448b37 | ||
| 572f8887e4 |
@@ -10,6 +10,7 @@
|
|||||||
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-light" href="/role-grants">Role grants</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
|
|||||||
@@ -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("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("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("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>
|
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -92,6 +93,10 @@ else
|
|||||||
{
|
{
|
||||||
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||||
}
|
}
|
||||||
|
else if (_tab == "redundancy")
|
||||||
|
{
|
||||||
|
<RedundancyTab ClusterId="@ClusterId"/>
|
||||||
|
}
|
||||||
else if (_tab == "audit")
|
else if (_tab == "audit")
|
||||||
{
|
{
|
||||||
<AuditTab ClusterId="@ClusterId"/>
|
<AuditTab ClusterId="@ClusterId"/>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId"/> }
|
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||||
@inject EquipmentService EquipmentSvc
|
@inject EquipmentService EquipmentSvc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<div class="d-flex justify-content-between mb-3">
|
<div class="d-flex justify-content-between mb-3">
|
||||||
<h4>Equipment (draft gen @GenerationId)</h4>
|
<h4>Equipment (draft gen @GenerationId)</h4>
|
||||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
<div>
|
||||||
|
<button class="btn btn-outline-primary btn-sm me-2" @onclick="GoImport">Import CSV…</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (_equipment is null)
|
@if (_equipment is null)
|
||||||
@@ -96,6 +100,9 @@ else if (_equipment.Count > 0)
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public long GenerationId { get; set; }
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private void GoImport() => Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}/import-equipment");
|
||||||
private List<Equipment>? _equipment;
|
private List<Equipment>? _equipment;
|
||||||
private bool _showForm;
|
private bool _showForm;
|
||||||
private bool _editMode;
|
private bool _editMode;
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment"
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@inject DriverInstanceService DriverSvc
|
||||||
|
@inject UnsService UnsSvc
|
||||||
|
@inject EquipmentImportBatchService BatchSvc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-0">Equipment CSV import</h1>
|
||||||
|
<small class="text-muted">Cluster <code>@ClusterId</code> · draft generation @GenerationId</small>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info small mb-3">
|
||||||
|
Accepts <code>@EquipmentCsvImporter.VersionMarker</code>-headered CSV per Stream B.3.
|
||||||
|
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
|
||||||
|
Optional columns cover the OPC 40010 Identification fields. Paste the file contents
|
||||||
|
or upload directly — the parser runs client-stream-side and shows a row-level preview
|
||||||
|
before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT
|
||||||
|
enforced here yet (see task #197); for now the finalise may fail at commit time if a
|
||||||
|
reservation conflict exists.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">Target driver instance (for every accepted row)</label>
|
||||||
|
<select class="form-select" @bind="_driverInstanceId">
|
||||||
|
<option value="">-- select driver --</option>
|
||||||
|
@if (_drivers is not null)
|
||||||
|
{
|
||||||
|
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.DriverInstanceId</option> }
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label">Target UNS line (for every accepted row)</label>
|
||||||
|
<select class="form-select" @bind="_unsLineId">
|
||||||
|
<option value="">-- select line --</option>
|
||||||
|
@if (_unsLines is not null)
|
||||||
|
{
|
||||||
|
@foreach (var l in _unsLines) { <option value="@l.UnsLineId">@l.UnsLineId — @l.Name</option> }
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 pt-4">
|
||||||
|
<InputFile OnChange="HandleFileAsync" class="form-control form-control-sm" accept=".csv,.txt"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">CSV content (paste or uploaded)</label>
|
||||||
|
<textarea class="form-control font-monospace" rows="8" @bind="_csvText"
|
||||||
|
placeholder="# OtOpcUaCsv v1 ZTag,MachineCode,SAPID,EquipmentId,…"/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>
|
||||||
|
<button class="btn btn-sm btn-primary ms-2" @onclick="StageAndFinaliseAsync"
|
||||||
|
disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)">
|
||||||
|
Stage + Finalise
|
||||||
|
</button>
|
||||||
|
@if (_parseError is not null) { <span class="alert alert-danger ms-3 py-1 px-2 small">@_parseError</span> }
|
||||||
|
@if (_result is not null) { <span class="alert alert-success ms-3 py-1 px-2 small">@_result</span> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_parseResult is not null)
|
||||||
|
{
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
Accepted (@_parseResult.AcceptedRows.Count)
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
@if (_parseResult.AcceptedRows.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted p-3 mb-0">No accepted rows.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _parseResult.AcceptedRows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><code>@r.ZTag</code></td>
|
||||||
|
<td>@r.MachineCode</td>
|
||||||
|
<td>@r.Name</td>
|
||||||
|
<td>@r.UnsLineName</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
Rejected (@_parseResult.RejectedRows.Count)
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
@if (_parseResult.RejectedRows.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted p-3 mb-0">No rejections.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-striped mb-0">
|
||||||
|
<thead><tr><th>Line</th><th>Reason</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var e in _parseResult.RejectedRows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@e.LineNumber</td>
|
||||||
|
<td class="small">@e.Reason</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
private List<DriverInstance>? _drivers;
|
||||||
|
private List<UnsLine>? _unsLines;
|
||||||
|
private string _driverInstanceId = string.Empty;
|
||||||
|
private string _unsLineId = string.Empty;
|
||||||
|
private string _csvText = string.Empty;
|
||||||
|
private EquipmentCsvParseResult? _parseResult;
|
||||||
|
private string? _parseError;
|
||||||
|
private string? _result;
|
||||||
|
private bool _busy;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
_unsLines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleFileAsync(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
// 5 MiB cap — refuses pathological uploads that would OOM the server.
|
||||||
|
using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
_csvText = await reader.ReadToEndAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ParseAsync()
|
||||||
|
{
|
||||||
|
_parseError = null;
|
||||||
|
_parseResult = null;
|
||||||
|
_result = null;
|
||||||
|
try { _parseResult = EquipmentCsvImporter.Parse(_csvText); }
|
||||||
|
catch (InvalidCsvFormatException ex) { _parseError = ex.Message; }
|
||||||
|
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StageAndFinaliseAsync()
|
||||||
|
{
|
||||||
|
if (_parseResult is null) return;
|
||||||
|
_busy = true;
|
||||||
|
_result = null;
|
||||||
|
_parseError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var auth = await AuthProvider.GetAuthenticationStateAsync();
|
||||||
|
var createdBy = auth.User.Identity?.Name ?? "unknown";
|
||||||
|
|
||||||
|
var batch = await BatchSvc.CreateBatchAsync(ClusterId, createdBy, CancellationToken.None);
|
||||||
|
await BatchSvc.StageRowsAsync(batch.Id, _parseResult.AcceptedRows, _parseResult.RejectedRows, CancellationToken.None);
|
||||||
|
await BatchSvc.FinaliseBatchAsync(batch.Id, GenerationId, _driverInstanceId, _unsLineId, CancellationToken.None);
|
||||||
|
|
||||||
|
_result = $"Finalised batch {batch.Id:N} — {_parseResult.AcceptedRows.Count} rows added.";
|
||||||
|
// Pause 600 ms so the success banner is visible, then navigate back.
|
||||||
|
await Task.Delay(600);
|
||||||
|
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _parseError = $"Finalise failed: {ex.Message}"; }
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'");
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
Normal file
161
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/RoleGrants.razor
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
@page "/role-grants"
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
||||||
|
@inject ILdapGroupRoleMappingService RoleSvc
|
||||||
|
@inject ClusterService ClusterSvc
|
||||||
|
|
||||||
|
<h1 class="mb-4">LDAP group → Admin role grants</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-info small mb-4">
|
||||||
|
Maps LDAP groups to Admin UI roles (ConfigViewer / ConfigEditor / FleetAdmin). Control-plane
|
||||||
|
only — OPC UA data-path authorization reads <code>NodeAcl</code> rows directly and is
|
||||||
|
unaffected by these mappings (see decision #150). A fleet-wide grant applies across every
|
||||||
|
cluster; a cluster-scoped grant only binds within the named cluster. The same LDAP group
|
||||||
|
may hold different roles on different clusters.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mb-3">
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_rows is null)
|
||||||
|
{
|
||||||
|
<p>Loading…</p>
|
||||||
|
}
|
||||||
|
else if (_rows.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No role grants defined yet. Without at least one FleetAdmin grant,
|
||||||
|
only the bootstrap admin can publish drafts.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _rows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><code>@r.LdapGroup</code></td>
|
||||||
|
<td><span class="badge bg-secondary">@r.Role</span></td>
|
||||||
|
<td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td>
|
||||||
|
<td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td class="small text-muted">@r.Notes</td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(r.Id)">Revoke</button></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showForm)
|
||||||
|
{
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>New role grant</h5>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">LDAP group (DN)</label>
|
||||||
|
<input class="form-control" @bind="_group" placeholder="cn=fleet-admin,ou=groups,dc=…"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<select class="form-select" @bind="_role">
|
||||||
|
@foreach (var r in Enum.GetValues<AdminRole>())
|
||||||
|
{
|
||||||
|
<option value="@r">@r</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 pt-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="systemWide" @bind="_isSystemWide"/>
|
||||||
|
<label class="form-check-label" for="systemWide">Fleet-wide</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Cluster @(_isSystemWide ? "(disabled)" : "")</label>
|
||||||
|
<select class="form-select" @bind="_clusterId" disabled="@_isSystemWide">
|
||||||
|
<option value="">-- select --</option>
|
||||||
|
@if (_clusters is not null)
|
||||||
|
{
|
||||||
|
@foreach (var c in _clusters)
|
||||||
|
{
|
||||||
|
<option value="@c.ClusterId">@c.ClusterId</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Notes (optional)</label>
|
||||||
|
<input class="form-control" @bind="_notes"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||||
|
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IReadOnlyList<LdapGroupRoleMapping>? _rows;
|
||||||
|
private List<ServerCluster>? _clusters;
|
||||||
|
private bool _showForm;
|
||||||
|
private string _group = string.Empty;
|
||||||
|
private AdminRole _role = AdminRole.ConfigViewer;
|
||||||
|
private bool _isSystemWide;
|
||||||
|
private string _clusterId = string.Empty;
|
||||||
|
private string? _notes;
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||||
|
|
||||||
|
private async Task ReloadAsync()
|
||||||
|
{
|
||||||
|
_rows = await RoleSvc.ListAllAsync(CancellationToken.None);
|
||||||
|
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartAdd()
|
||||||
|
{
|
||||||
|
_group = string.Empty;
|
||||||
|
_role = AdminRole.ConfigViewer;
|
||||||
|
_isSystemWide = false;
|
||||||
|
_clusterId = string.Empty;
|
||||||
|
_notes = null;
|
||||||
|
_error = null;
|
||||||
|
_showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
_error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var row = new LdapGroupRoleMapping
|
||||||
|
{
|
||||||
|
LdapGroup = _group.Trim(),
|
||||||
|
Role = _role,
|
||||||
|
IsSystemWide = _isSystemWide,
|
||||||
|
ClusterId = _isSystemWide ? null : (string.IsNullOrWhiteSpace(_clusterId) ? null : _clusterId),
|
||||||
|
Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes,
|
||||||
|
};
|
||||||
|
await RoleSvc.CreateAsync(row, CancellationToken.None);
|
||||||
|
_showForm = false;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Guid id)
|
||||||
|
{
|
||||||
|
await RoleSvc.DeleteAsync(id, CancellationToken.None);
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ builder.Services.AddScoped<ReservationService>();
|
|||||||
builder.Services.AddScoped<DraftValidationService>();
|
builder.Services.AddScoped<DraftValidationService>();
|
||||||
builder.Services.AddScoped<AuditLogService>();
|
builder.Services.AddScoped<AuditLogService>();
|
||||||
builder.Services.AddScoped<HostStatusService>();
|
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>();
|
||||||
|
|
||||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||||
|
|||||||
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