Compare commits
10 Commits
identifica
...
uns-tab-dr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ee510dc1a | ||
| 543665dedd | |||
|
|
c8a38bc57b | ||
| cecb84fa5d | |||
|
|
13d5a7968b | ||
| d1686ed82d | |||
|
|
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"/>
|
||||||
|
|||||||
@@ -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
|
else
|
||||||
{
|
{
|
||||||
<table class="table table-hover table-sm">
|
<p class="small text-muted mb-3">
|
||||||
<thead><tr><th>Table</th><th>LogicalId</th><th>ChangeKind</th></tr></thead>
|
@_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections.
|
||||||
<tbody>
|
Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts.
|
||||||
@foreach (var r in _rows)
|
</p>
|
||||||
|
|
||||||
|
@foreach (var sec in Sections)
|
||||||
{
|
{
|
||||||
<tr>
|
<DiffSection Title="@sec.Title"
|
||||||
<td>@r.TableName</td>
|
Description="@sec.Description"
|
||||||
<td><code>@r.LogicalId</code></td>
|
Rows="@RowsFor(sec.TableName)"/>
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
[Parameter] public long GenerationId { get; set; }
|
[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 List<DiffRow>? _rows;
|
||||||
private string _fromLabel = "(empty)";
|
private string _fromLabel = "(empty)";
|
||||||
private string? _error;
|
private string? _error;
|
||||||
|
private int _sectionsWithChanges;
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -67,7 +75,13 @@ else
|
|||||||
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||||
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||||
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
_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; }
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +2,15 @@
|
|||||||
@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>
|
||||||
|
<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>
|
<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'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
@inject UnsService UnsSvc
|
@inject UnsService UnsSvc
|
||||||
|
|
||||||
|
<div class="alert alert-info small mb-3">
|
||||||
|
Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong>
|
||||||
|
to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm
|
||||||
|
or cancel. If another operator modifies the draft while you're confirming, you'll see a 409
|
||||||
|
refresh-required modal instead of clobbering their work.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
@@ -14,11 +21,20 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead><tr><th>AreaId</th><th>Name</th></tr></thead>
|
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var a in _areas)
|
@foreach (var a in _areas)
|
||||||
{
|
{
|
||||||
<tr><td><code>@a.UnsAreaId</code></td><td>@a.Name</td></tr>
|
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
|
||||||
|
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
|
||||||
|
@ondragover:preventDefault
|
||||||
|
@ondragleave="() => _hoverAreaId = null"
|
||||||
|
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
|
||||||
|
@ondrop:preventDefault>
|
||||||
|
<td><code>@a.UnsAreaId</code></td>
|
||||||
|
<td>@a.Name</td>
|
||||||
|
<td class="small text-muted">drop here</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -35,6 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="d-flex justify-content-between mb-2">
|
<div class="d-flex justify-content-between mb-2">
|
||||||
<h4>UNS Lines</h4>
|
<h4>UNS Lines</h4>
|
||||||
@@ -50,7 +67,14 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var l in _lines)
|
@foreach (var l in _lines)
|
||||||
{
|
{
|
||||||
<tr><td><code>@l.UnsLineId</code></td><td><code>@l.UnsAreaId</code></td><td>@l.Name</td></tr>
|
<tr draggable="true"
|
||||||
|
@ondragstart="() => _dragLineId = l.UnsLineId"
|
||||||
|
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
|
||||||
|
style="cursor: grab;">
|
||||||
|
<td><code>@l.UnsLineId</code></td>
|
||||||
|
<td><code>@l.UnsAreaId</code></td>
|
||||||
|
<td>@l.Name</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -75,6 +99,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* Preview / confirm modal for a pending drag-drop move *@
|
||||||
|
@if (_pendingPreview is not null)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Confirm UNS move</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="CancelMove"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>@_pendingPreview.HumanReadableSummary</p>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Equipment re-homed: <strong>@_pendingPreview.AffectedEquipmentCount</strong>.
|
||||||
|
Tags re-parented: <strong>@_pendingPreview.AffectedTagCount</strong>.
|
||||||
|
</p>
|
||||||
|
@if (_pendingPreview.CascadeWarnings.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning small mb-0">
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" @onclick="CancelMove">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @onclick="ConfirmMoveAsync" disabled="@_committing">Confirm move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@
|
||||||
|
@if (_conflictMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content border-danger">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title">Draft changed — refresh required</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>@_conflictMessage</p>
|
||||||
|
<p class="small text-muted">
|
||||||
|
Concurrency guard per DraftRevisionToken prevented overwriting the peer
|
||||||
|
operator's edit. Reload the tab + redo the move on the current draft state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-primary" @onclick="ReloadAfterConflict">Reload draft</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public long GenerationId { get; set; }
|
[Parameter] public long GenerationId { get; set; }
|
||||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
@@ -87,6 +169,13 @@
|
|||||||
private string _newLineName = string.Empty;
|
private string _newLineName = string.Empty;
|
||||||
private string _newLineAreaId = string.Empty;
|
private string _newLineAreaId = string.Empty;
|
||||||
|
|
||||||
|
private string? _dragLineId;
|
||||||
|
private string? _hoverAreaId;
|
||||||
|
private UnsImpactPreview? _pendingPreview;
|
||||||
|
private UnsMoveOperation? _pendingMove;
|
||||||
|
private bool _committing;
|
||||||
|
private string? _conflictMessage;
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||||
|
|
||||||
private async Task ReloadAsync()
|
private async Task ReloadAsync()
|
||||||
@@ -112,4 +201,72 @@
|
|||||||
_showLineForm = false;
|
_showLineForm = false;
|
||||||
await ReloadAsync();
|
await ReloadAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId;
|
||||||
|
|
||||||
|
private async Task OnLineDroppedAsync(string targetAreaId)
|
||||||
|
{
|
||||||
|
var lineId = _dragLineId;
|
||||||
|
_hoverAreaId = null;
|
||||||
|
_dragLineId = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(lineId)) return;
|
||||||
|
|
||||||
|
var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId);
|
||||||
|
if (line is null || line.UnsAreaId == targetAreaId) return;
|
||||||
|
|
||||||
|
var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None);
|
||||||
|
var move = new UnsMoveOperation(
|
||||||
|
Kind: UnsMoveKind.LineMove,
|
||||||
|
SourceClusterId: ClusterId,
|
||||||
|
TargetClusterId: ClusterId,
|
||||||
|
SourceLineId: lineId,
|
||||||
|
TargetAreaId: targetAreaId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||||
|
_pendingMove = move;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelMove()
|
||||||
|
{
|
||||||
|
_pendingPreview = null;
|
||||||
|
_pendingMove = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfirmMoveAsync()
|
||||||
|
{
|
||||||
|
if (_pendingPreview is null || _pendingMove is null) return;
|
||||||
|
_committing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await UnsSvc.MoveLineAsync(
|
||||||
|
GenerationId,
|
||||||
|
_pendingPreview.RevisionToken,
|
||||||
|
_pendingMove.SourceLineId!,
|
||||||
|
_pendingMove.TargetAreaId!,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
_pendingPreview = null;
|
||||||
|
_pendingMove = null;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (DraftRevisionConflictException ex)
|
||||||
|
{
|
||||||
|
_pendingPreview = null;
|
||||||
|
_pendingMove = null;
|
||||||
|
_conflictMessage = ex.Message;
|
||||||
|
}
|
||||||
|
finally { _committing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadAfterConflict()
|
||||||
|
{
|
||||||
|
_conflictMessage = null;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
@@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db)
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft
|
||||||
|
/// and counts equipment + tags per line. Returns the snapshot plus a deterministic
|
||||||
|
/// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples —
|
||||||
|
/// stable across processes + changes whenever any row is added / modified / deleted.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<UnsTreeSnapshot> LoadSnapshotAsync(long generationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var areas = await db.UnsAreas.AsNoTracking()
|
||||||
|
.Where(a => a.GenerationId == generationId)
|
||||||
|
.OrderBy(a => a.UnsAreaId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var lines = await db.UnsLines.AsNoTracking()
|
||||||
|
.Where(l => l.GenerationId == generationId)
|
||||||
|
.OrderBy(l => l.UnsLineId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var equipmentCounts = await db.Equipment.AsNoTracking()
|
||||||
|
.Where(e => e.GenerationId == generationId)
|
||||||
|
.GroupBy(e => e.UnsLineId)
|
||||||
|
.Select(g => new { LineId = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var lineSummaries = lines.Select(l =>
|
||||||
|
new UnsLineSummary(
|
||||||
|
LineId: l.UnsLineId,
|
||||||
|
Name: l.Name,
|
||||||
|
EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId),
|
||||||
|
TagCount: 0)).ToList();
|
||||||
|
|
||||||
|
var areaSummaries = areas.Select(a =>
|
||||||
|
new UnsAreaSummary(
|
||||||
|
AreaId: a.UnsAreaId,
|
||||||
|
Name: a.Name,
|
||||||
|
LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(l => l.UnsLineId).ToList())).ToList();
|
||||||
|
|
||||||
|
return new UnsTreeSnapshot
|
||||||
|
{
|
||||||
|
DraftGenerationId = generationId,
|
||||||
|
RevisionToken = ComputeRevisionToken(areas, lines),
|
||||||
|
Areas = areaSummaries,
|
||||||
|
Lines = lineSummaries,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Atomic re-parent of a line to a new area inside the same draft. The caller must pass
|
||||||
|
/// the revision token it observed at preview time — a mismatch raises
|
||||||
|
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
||||||
|
/// modal instead of silently overwriting a peer's work.
|
||||||
|
/// </summary>
|
||||||
|
public async Task MoveLineAsync(
|
||||||
|
long generationId,
|
||||||
|
DraftRevisionToken expected,
|
||||||
|
string lineId,
|
||||||
|
string targetAreaId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expected);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(lineId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId);
|
||||||
|
|
||||||
|
var supportsTx = db.Database.IsRelational();
|
||||||
|
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||||
|
if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var areas = await db.UnsAreas
|
||||||
|
.Where(a => a.GenerationId == generationId)
|
||||||
|
.OrderBy(a => a.UnsAreaId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var lines = await db.UnsLines
|
||||||
|
.Where(l => l.GenerationId == generationId)
|
||||||
|
.OrderBy(l => l.UnsLineId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var current = ComputeRevisionToken(areas, lines);
|
||||||
|
if (!current.Matches(expected))
|
||||||
|
throw new DraftRevisionConflictException(
|
||||||
|
$"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " +
|
||||||
|
"Refresh + redo the move.");
|
||||||
|
|
||||||
|
var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}.");
|
||||||
|
|
||||||
|
if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}.");
|
||||||
|
|
||||||
|
if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return; // no-op drop — same area
|
||||||
|
|
||||||
|
line.UnsAreaId = targetAreaId;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList<UnsArea> areas, IReadOnlyList<UnsLine> lines)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80);
|
||||||
|
foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal))
|
||||||
|
sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';');
|
||||||
|
foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal))
|
||||||
|
sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';');
|
||||||
|
|
||||||
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||||
|
return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thrown when a UNS move's expected revision token no longer matches the live draft
|
||||||
|
/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style
|
||||||
|
/// "refresh required" modal in the Admin UI.</summary>
|
||||||
|
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
Normal file
130
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class UnsServiceMoveTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadSnapshotAsync_ReturnsAllAreasAndLines_WithEquipmentCounts()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||||
|
lines: new[] { ("line-a", "area-1"), ("line-b", "area-1"), ("line-c", "area-2") },
|
||||||
|
equipmentLines: new[] { "line-a", "line-a", "line-b" });
|
||||||
|
var svc = new UnsService(ctx);
|
||||||
|
|
||||||
|
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||||
|
|
||||||
|
snap.Areas.Count.ShouldBe(2);
|
||||||
|
snap.Lines.Count.ShouldBe(3);
|
||||||
|
snap.FindLine("line-a")!.EquipmentCount.ShouldBe(2);
|
||||||
|
snap.FindLine("line-b")!.EquipmentCount.ShouldBe(1);
|
||||||
|
snap.FindLine("line-c")!.EquipmentCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadSnapshotAsync_RevisionToken_IsStable_BetweenTwoReads()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
|
||||||
|
var svc = new UnsService(ctx);
|
||||||
|
|
||||||
|
var first = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||||
|
var second = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||||
|
|
||||||
|
second.RevisionToken.Matches(first.RevisionToken).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadSnapshotAsync_RevisionToken_Changes_When_LineAdded()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
|
||||||
|
var svc = new UnsService(ctx);
|
||||||
|
|
||||||
|
var before = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||||
|
await svc.AddLineAsync(1, "area-1", "new-line", null, CancellationToken.None);
|
||||||
|
var after = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||||
|
|
||||||
|
after.RevisionToken.Matches(before.RevisionToken).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLineAsync_WithMatchingToken_Reparents_Line()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||||
|
lines: new[] { ("line-a", "area-1") });
|
||||||
|
var svc = new UnsService(ctx);
|
||||||
|
|
||||||
|
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||||
|
await svc.MoveLineAsync(1, snap.RevisionToken, "line-a", "area-2", CancellationToken.None);
|
||||||
|
|
||||||
|
var moved = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
|
||||||
|
moved.UnsAreaId.ShouldBe("area-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLineAsync_WithStaleToken_Throws_DraftRevisionConflict()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||||
|
lines: new[] { ("line-a", "area-1") });
|
||||||
|
var svc = new UnsService(ctx);
|
||||||
|
|
||||||
|
// Simulate a peer operator's concurrent edit between our preview + commit.
|
||||||
|
var stale = new DraftRevisionToken("0000000000000000");
|
||||||
|
|
||||||
|
await Should.ThrowAsync<DraftRevisionConflictException>(() =>
|
||||||
|
svc.MoveLineAsync(1, stale, "line-a", "area-2", CancellationToken.None));
|
||||||
|
|
||||||
|
var row = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
|
||||||
|
row.UnsAreaId.ShouldBe("area-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Seed(OtOpcUaConfigDbContext ctx, long draftId,
|
||||||
|
IEnumerable<string> areas,
|
||||||
|
IEnumerable<(string line, string area)> lines,
|
||||||
|
IEnumerable<string>? equipmentLines = null)
|
||||||
|
{
|
||||||
|
foreach (var a in areas)
|
||||||
|
{
|
||||||
|
ctx.UnsAreas.Add(new UnsArea
|
||||||
|
{
|
||||||
|
GenerationId = draftId, UnsAreaId = a, ClusterId = "c1", Name = a,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (var (line, area) in lines)
|
||||||
|
{
|
||||||
|
ctx.UnsLines.Add(new UnsLine
|
||||||
|
{
|
||||||
|
GenerationId = draftId, UnsLineId = line, UnsAreaId = area, Name = line,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (var lineId in equipmentLines ?? [])
|
||||||
|
{
|
||||||
|
ctx.Equipment.Add(new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = draftId,
|
||||||
|
EquipmentId = $"EQ-{Guid.NewGuid():N}"[..15],
|
||||||
|
EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv",
|
||||||
|
UnsLineId = lineId, Name = "x", MachineCode = "m",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
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