b76561a780
19 tasks across WS1 (driver collection editors), WS2 (typed resilience form), WS3 (editable DB-backed LDAP role map, global), WS4 (cleanup).
1029 lines
46 KiB
Markdown
1029 lines
46 KiB
Markdown
# AdminUI Deferred Follow-ups Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Complete the real deferred follow-ups behind the AdminUI's "Phase C.2 / follow-up" notes — driver collection editors (tags/devices/endpoints), a typed resilience-override form, and editable DB-backed LDAP→role mapping — then strip the now-obsolete notes and stale source comments.
|
||
|
||
**Architecture:** A generic modal-per-row `CollectionEditor<TRow>` Blazor component edits in-memory lists that the existing driver-page Save already serializes into `DriverInstance.DriverConfig`. The resilience textarea becomes a typed form bound to a nullable `ResilienceFormModel` that emits the same override JSON the runtime parser reads. LDAP role mapping moves to the already-existing `LdapGroupRoleMapping` DB entity (global rows only), wired into the cookie-login path with the appsettings map as a fallback baseline.
|
||
|
||
**Tech Stack:** .NET 10, Blazor Server (InteractiveServer), EF Core (`OtOpcUaConfigDbContext`), xUnit + Shouldly, Bootstrap 5 markup. No bUnit — logic is tested through plain mapping/merge methods + EF in-memory.
|
||
|
||
**Branch:** `feat/adminui-followups` (design doc: `docs/plans/2026-05-29-adminui-followups-design.md`).
|
||
|
||
**Key facts (verified during planning):**
|
||
- Driver tag/device lists live in `DriverConfig` JSON and ARE the runtime source of truth (driver factories deserialize and poll them). The canonical `Tag` table is orthogonal.
|
||
- Driver tag/device contracts are **immutable positional records** → editors bind to **mutable row VMs** that map to/from the record.
|
||
- Resilience override JSON shape (parser `DriverResilienceOptionsParser`, case-insensitive keys):
|
||
`{ "bulkheadMaxConcurrent": int?, "bulkheadMaxQueue": int?, "recycleIntervalSeconds": int?, "capabilityPolicies": { "<Capability>": { "timeoutSeconds": int?, "retryCount": int?, "breakerFailureThreshold": int? } } }`. Capabilities: `Read, Write, Discover, Subscribe, Probe, AlarmSubscribe, AlarmAcknowledge, HistoryRead`.
|
||
- LDAP login: `AuthEndpoints.LoginAsync` builds role claims from `result.Roles` (which `LdapAuthService` computes from appsettings `GroupToRole`). `ILdapGroupRoleMappingService` (CRUD over `LdapGroupRoleMapping`, `AdminRole` enum: `ConfigViewer/ConfigEditor/FleetAdmin`) exists but is **not registered in DI** and **not wired** into login.
|
||
- Auth policies live in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs` (`AddAuthorization`, fallback `RequireAuthenticatedUser`, existing `DriverOperator` policy).
|
||
- Test projects: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` (per-driver `*FormSerializationTests.cs`), `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests` (`RoleMapperTests.cs`, `AuthEndpointsIntegrationTests.cs`), `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests` (`LdapGroupRoleMappingServiceTests.cs`).
|
||
|
||
**Global conventions for every task:**
|
||
- Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` after edits; it must be clean.
|
||
- Commit at the end of each task with the message shown.
|
||
- `IReadOnlyList<...>` collection round-trip tests use the page's `_jsonOpts` (camelCase, `UnmappedMemberHandling.Skip`).
|
||
|
||
---
|
||
|
||
## WS1 — Driver collection editors
|
||
|
||
### Task 1: Generic `CollectionEditor<TRow>` shared component
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 9, Task 11, Task 12, Task 14, Task 16
|
||
|
||
**Files:**
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/CollectionEditor.razor`
|
||
|
||
This is a presentation component; no unit test (no bUnit). It is exercised by the Modbus proof (Task 2) and `/run` (Task 19). Keep ALL logic in the component trivial; per-driver mapping/validation is tested in Tasks 2–8.
|
||
|
||
**Step 1: Write the component**
|
||
|
||
```razor
|
||
@* Generic modal-per-row list editor. The parent owns the List<TRow> (a MUTABLE row VM,
|
||
because driver contracts are immutable records). This renders a read-only table with
|
||
Add/Edit/Delete and a modal that edits a CLONED working copy — commit on Save, discard
|
||
on Cancel. NewRow builds a default VM; Clone copies one for the working copy; Validate
|
||
(optional) returns an error string to block commit or null to allow. *@
|
||
@typeparam TRow
|
||
|
||
<section class="panel rise mt-3" style="@_styleDelay">
|
||
<div class="panel-head d-flex align-items-center">
|
||
<span>@Title (@Items.Count)</span>
|
||
<button type="button" class="btn btn-sm btn-outline-primary ms-auto" @onclick="Add">+ Add @ItemNoun</button>
|
||
</div>
|
||
@if (Items.Count == 0)
|
||
{
|
||
<div style="padding:1rem" class="text-muted">No @ItemNoun.ToLowerInvariant() rows.</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="table-wrap">
|
||
<table class="data-table">
|
||
<thead>@HeaderTemplate</thead>
|
||
<tbody>
|
||
@for (var i = 0; i < Items.Count; i++)
|
||
{
|
||
var idx = i;
|
||
<tr>
|
||
@RowTemplate(Items[idx])
|
||
<td class="text-end" style="white-space:nowrap">
|
||
<button type="button" class="btn btn-sm btn-link p-0 me-2" @onclick="() => Edit(idx)">Edit</button>
|
||
<button type="button" class="btn btn-sm btn-link p-0 text-danger" @onclick="() => Delete(idx)">Delete</button>
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
</section>
|
||
|
||
@if (_modalOpen && _working is not null)
|
||
{
|
||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||
<div class="modal-dialog modal-lg" role="document">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">@(_editIndex is null ? $"Add {ItemNoun}" : $"Edit {ItemNoun}")</h5>
|
||
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
@EditTemplate(_working)
|
||
@if (!string.IsNullOrEmpty(_validationError))
|
||
{
|
||
<div class="text-danger small mt-2">@_validationError</div>
|
||
}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
||
<button type="button" class="btn btn-primary" @onclick="Commit">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@code {
|
||
[Parameter, EditorRequired] public List<TRow> Items { get; set; } = default!;
|
||
[Parameter] public EventCallback ItemsChanged { get; set; }
|
||
[Parameter] public string Title { get; set; } = "Items";
|
||
[Parameter] public string ItemNoun { get; set; } = "row";
|
||
[Parameter] public string AnimationDelay { get; set; } = ".18s";
|
||
[Parameter, EditorRequired] public RenderFragment HeaderTemplate { get; set; } = default!;
|
||
[Parameter, EditorRequired] public RenderFragment<TRow> RowTemplate { get; set; } = default!;
|
||
[Parameter, EditorRequired] public RenderFragment<TRow> EditTemplate { get; set; } = default!;
|
||
[Parameter, EditorRequired] public Func<TRow> NewRow { get; set; } = default!;
|
||
[Parameter, EditorRequired] public Func<TRow, TRow> Clone { get; set; } = default!;
|
||
[Parameter] public Func<TRow, IReadOnlyList<TRow>, int?, string?>? Validate { get; set; }
|
||
|
||
private string _styleDelay => $"animation-delay:{AnimationDelay}";
|
||
private bool _modalOpen;
|
||
private int? _editIndex;
|
||
private TRow? _working;
|
||
private string? _validationError;
|
||
|
||
private void Add()
|
||
{
|
||
_editIndex = null;
|
||
_working = NewRow();
|
||
_validationError = null;
|
||
_modalOpen = true;
|
||
}
|
||
|
||
private void Edit(int index)
|
||
{
|
||
_editIndex = index;
|
||
_working = Clone(Items[index]);
|
||
_validationError = null;
|
||
_modalOpen = true;
|
||
}
|
||
|
||
private async Task Delete(int index)
|
||
{
|
||
Items.RemoveAt(index);
|
||
await ItemsChanged.InvokeAsync();
|
||
}
|
||
|
||
private void Cancel()
|
||
{
|
||
_modalOpen = false;
|
||
_working = default;
|
||
_editIndex = null;
|
||
_validationError = null;
|
||
}
|
||
|
||
private async Task Commit()
|
||
{
|
||
if (_working is null) return;
|
||
_validationError = Validate?.Invoke(_working, Items, _editIndex);
|
||
if (_validationError is not null) return;
|
||
|
||
if (_editIndex is int i) Items[i] = _working;
|
||
else Items.Add(_working);
|
||
|
||
_modalOpen = false;
|
||
_working = default;
|
||
_editIndex = null;
|
||
await ItemsChanged.InvokeAsync();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Build**
|
||
|
||
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj`
|
||
Expected: Build succeeded, 0 errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/CollectionEditor.razor
|
||
git commit -m "feat(adminui): add generic CollectionEditor<TRow> modal list editor"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Wire Modbus tag editor (proof) + round-trip test
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (reference task — Tasks 3-8 mirror it; do this first)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor`
|
||
- Reference (read for field names): `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs:283` (`ModbusTagDefinition`)
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs`
|
||
|
||
`ModbusTagDefinition(string Name, ModbusRegion Region, ushort Address, ModbusDataType DataType, bool Writable=true, ModbusByteOrder ByteOrder=BigEndian, byte BitIndex=0, ushort StringLength=0, ModbusStringByteOrder StringByteOrder=HighByteFirst, bool WriteIdempotent=false, int? ArrayCount=null, double? Deadband=null, byte? UnitId=null, bool CoalesceProhibited=false)`.
|
||
|
||
**Step 1: Add a mutable row VM + mapping inside `ModbusDriverPage.razor` `@code`**
|
||
|
||
Add this nested class beside `FormModel`:
|
||
|
||
```csharp
|
||
// Mutable VM for the modal editor — ModbusTagDefinition is an immutable record.
|
||
public sealed class ModbusTagRow
|
||
{
|
||
public string Name { get; set; } = "";
|
||
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegister;
|
||
public int Address { get; set; }
|
||
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
|
||
public bool Writable { get; set; } = true;
|
||
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
|
||
public int BitIndex { get; set; }
|
||
public int StringLength { get; set; }
|
||
public bool WriteIdempotent { get; set; }
|
||
|
||
public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone();
|
||
|
||
public static ModbusTagRow FromDefinition(ModbusTagDefinition d) => new()
|
||
{
|
||
Name = d.Name, Region = d.Region, Address = d.Address, DataType = d.DataType,
|
||
Writable = d.Writable, ByteOrder = d.ByteOrder, BitIndex = d.BitIndex,
|
||
StringLength = d.StringLength, WriteIdempotent = d.WriteIdempotent,
|
||
};
|
||
|
||
public ModbusTagDefinition ToDefinition() => new(
|
||
Name: Name.Trim(), Region: Region, Address: (ushort)Math.Clamp(Address, 0, 65535),
|
||
DataType: DataType, Writable: Writable, ByteOrder: ByteOrder,
|
||
BitIndex: (byte)Math.Clamp(BitIndex, 0, 255), StringLength: (ushort)Math.Clamp(StringLength, 0, 65535),
|
||
WriteIdempotent: WriteIdempotent);
|
||
|
||
public static string? ValidateRow(ModbusTagRow row, IReadOnlyList<ModbusTagRow> all, int? editIndex)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
|
||
for (var i = 0; i < all.Count; i++)
|
||
if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
|
||
return $"Duplicate tag name '{row.Name}'.";
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Replace the `_tags` field + load/save plumbing**
|
||
|
||
Replace:
|
||
```csharp
|
||
private IReadOnlyList<ModbusTagDefinition> _tags = [];
|
||
private string _tagsJson = "[]";
|
||
```
|
||
with:
|
||
```csharp
|
||
private List<ModbusTagRow> _tags = [];
|
||
```
|
||
|
||
In `OnInitializedAsync`, replace `_tags = opts.Tags;` with:
|
||
```csharp
|
||
_tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList();
|
||
```
|
||
and delete the line `_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);` (both the one inside the else block context and the trailing one before `_loaded = true;`). After this change there is no `_tagsJson`.
|
||
|
||
In `SubmitAsync` and `SerializeCurrentConfig`, replace `_form.ToOptions(_tags)` with:
|
||
```csharp
|
||
_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList())
|
||
```
|
||
|
||
**Step 3: Replace the read-only Tags section markup**
|
||
|
||
Replace the whole `@* Tags — read-only JSON view *@ <section>…</section>` block (lines ~276-285) with:
|
||
|
||
```razor
|
||
<CollectionEditor TRow="ModbusTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
|
||
NewRow="@(() => new ModbusTagRow())" Clone="@(r => r.Clone())"
|
||
Validate="ModbusTagRow.ValidateRow">
|
||
<HeaderTemplate>
|
||
<tr><th>Name</th><th>Region</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
|
||
</HeaderTemplate>
|
||
<RowTemplate Context="t">
|
||
<td class="mono">@t.Name</td><td>@t.Region</td><td class="mono">@t.Address</td>
|
||
<td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
|
||
</RowTemplate>
|
||
<EditTemplate Context="t">
|
||
<div class="row g-3">
|
||
<div class="col-md-6"><label class="form-label">Name</label>
|
||
<input class="form-control form-control-sm" @bind="t.Name" /></div>
|
||
<div class="col-md-3"><label class="form-label">Region</label>
|
||
<select class="form-select form-select-sm" @bind="t.Region">
|
||
@foreach (var e in Enum.GetValues<ModbusRegion>()) { <option value="@e">@e</option> }
|
||
</select></div>
|
||
<div class="col-md-3"><label class="form-label">Address</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="t.Address" /></div>
|
||
<div class="col-md-3"><label class="form-label">Data type</label>
|
||
<select class="form-select form-select-sm" @bind="t.DataType">
|
||
@foreach (var e in Enum.GetValues<ModbusDataType>()) { <option value="@e">@e</option> }
|
||
</select></div>
|
||
<div class="col-md-3"><label class="form-label">Byte order</label>
|
||
<select class="form-select form-select-sm" @bind="t.ByteOrder">
|
||
@foreach (var e in Enum.GetValues<ModbusByteOrder>()) { <option value="@e">@e</option> }
|
||
</select></div>
|
||
<div class="col-md-2"><label class="form-label">Bit index</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="t.BitIndex" /></div>
|
||
<div class="col-md-2"><label class="form-label">String len</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" /></div>
|
||
<div class="col-md-2"><div class="form-check form-switch mt-4">
|
||
<input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
|
||
<label class="form-check-label" for="tagWritable">Writable</label></div></div>
|
||
</div>
|
||
</EditTemplate>
|
||
</CollectionEditor>
|
||
```
|
||
|
||
(Leave the existing address-picker UI intact — it stays useful for copying an address into the modal.)
|
||
|
||
**Step 4: Write the failing test**
|
||
|
||
Add to `ModbusDriverPageFormSerializationTests.cs`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void TagRow_round_trips_through_definition()
|
||
{
|
||
var row = new ModbusDriverPage.ModbusTagRow
|
||
{
|
||
Name = "Pump1_Speed", Region = ModbusRegion.HoldingRegister, Address = 40001,
|
||
DataType = ModbusDataType.Int16, Writable = true,
|
||
};
|
||
var def = row.ToDefinition();
|
||
var back = ModbusDriverPage.ModbusTagRow.FromDefinition(def);
|
||
|
||
back.Name.ShouldBe("Pump1_Speed");
|
||
back.Address.ShouldBe(40001);
|
||
back.DataType.ShouldBe(ModbusDataType.Int16);
|
||
back.Writable.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact]
|
||
public void Tag_list_survives_options_serialize_round_trip()
|
||
{
|
||
var tags = new List<ModbusTagDefinition>
|
||
{
|
||
new("A", ModbusRegion.HoldingRegister, 1, ModbusDataType.Int16),
|
||
new("B", ModbusRegion.Coil, 2, ModbusDataType.Bool),
|
||
};
|
||
var opts = new ModbusDriverPage.FormModel().ToOptions(tags);
|
||
var json = JsonSerializer.Serialize(opts, TestJsonOpts); // mirror page _jsonOpts (camelCase, Skip)
|
||
var back = JsonSerializer.Deserialize<ModbusDriverOptions>(json, TestJsonOpts)!;
|
||
back.Tags.Count.ShouldBe(2);
|
||
back.Tags[0].Name.ShouldBe("A");
|
||
}
|
||
|
||
[Fact]
|
||
public void ValidateRow_rejects_duplicate_name()
|
||
{
|
||
var rows = new List<ModbusDriverPage.ModbusTagRow> { new() { Name = "A" } };
|
||
ModbusDriverPage.ModbusTagRow.ValidateRow(new() { Name = "A" }, rows, null)
|
||
.ShouldNotBeNull();
|
||
}
|
||
```
|
||
|
||
If `TestJsonOpts` isn't already present in the test file, add a private static matching the page: `new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }`.
|
||
|
||
**Step 5: Run tests**
|
||
|
||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ModbusDriverPage"`
|
||
Expected: PASS (3 new tests green).
|
||
|
||
**Step 6: Build the AdminUI project (Razor compile)**
|
||
|
||
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj`
|
||
Expected: 0 errors.
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs
|
||
git commit -m "feat(adminui): editable Modbus tag list via CollectionEditor"
|
||
```
|
||
|
||
---
|
||
|
||
### Tasks 3-8: Roll out editors to remaining drivers (mirror Task 2)
|
||
|
||
Each task follows the **exact** Task 2 pattern for its driver page: (a) read the driver's contract record(s), (b) add a mutable row VM (`<Driver><Tag|Device|Endpoint>Row`) with `Clone()`, `FromX`/`ToX`, and a `ValidateRow` (required key + duplicate check), (c) change the `_tags`/`_devices`/`_endpoints` field to `List<VM>`, fix load (`…Select(VM.FromX).ToList()`) and save (`…Select(r => r.ToX()).ToList()`), (d) replace each read-only `<pre>` section with a `<CollectionEditor>`, (e) add round-trip + validate tests to the driver's existing `*FormSerializationTests.cs`, (f) build + test + commit.
|
||
|
||
**Classification:** standard · **Estimated implement time:** ~5 min each · **Parallelizable with:** each other and Tasks 9, 11, 12, 14, 16 (distinct files). All depend on Task 1; use Task 2 as the template.
|
||
|
||
| Task | Driver page (`…/Components/Pages/Clusters/Drivers/`) | Contract(s) to read | Editors | Test file |
|
||
|---|---|---|---|---|
|
||
| 3 | `AbCipDriverPage.razor` | `Driver.AbCip.Contracts/AbCipDriverOptions.cs` (device + tag records) | Devices, Tags | `AbCipDriverPageFormSerializationTests.cs` |
|
||
| 4 | `AbLegacyDriverPage.razor` | `Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs` | Devices, Tags | `AbLegacyDriverPageFormSerializationTests.cs` |
|
||
| 5 | `TwinCATDriverPage.razor` | `Driver.TwinCAT*/…Options.cs` | Devices, Tags | `TwinCATDriverPageFormSerializationTests.cs` |
|
||
| 6 | `FocasDriverPage.razor` | `Driver.FOCAS*/…Options.cs` (device has `series` enum) | Devices, Tags | `FocasDriverPageFormSerializationTests.cs` |
|
||
| 7 | `S7DriverPage.razor` | `Driver.S7*/…Options.cs` (tag record) | Tags | `S7DriverPageFormSerializationTests.cs` |
|
||
| 8 | `OpcUaClientDriverPage.razor` | `OpcUaClientDriverOptions.EndpointUrls` (List<string>) | Endpoint URLs | `OpcUaClientDriverPageFormSerializationTests.cs` |
|
||
|
||
Notes:
|
||
- Task 8 endpoint rows: VM is `sealed class EndpointUrlRow { public string Url { get; set; } = ""; public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone(); }`; map to/from `string`; validate non-empty + well-formed `opc.tcp://`. Leave `UnsMappingTable`'s read-only JSON as-is (out of scope; only the endpoint list note is removed in Task 18).
|
||
- Device deletes: keep a `Validate`/confirm note that tags referencing the device by host address may break — surface it in the modal helper text; no cascade logic.
|
||
- Commit message per task: `feat(adminui): editable <driver> tag/device list via CollectionEditor`.
|
||
|
||
---
|
||
|
||
## WS2 — Resilience typed form
|
||
|
||
### Task 9: `ResilienceFormModel` (FromJson/ToJson) + tests
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Tasks 1-8, 11, 12, 14, 16
|
||
|
||
**Files:**
|
||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs`
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs`
|
||
- Reference: `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs` (JSON shape, case-insensitive)
|
||
|
||
**Step 1: Write the model**
|
||
|
||
```csharp
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||
|
||
/// <summary>
|
||
/// Mutable, all-nullable form model for the driver resilience override. Binds the typed
|
||
/// fields in DriverResilienceSection; null/blank = "use the driver's tier default", so a
|
||
/// blank form serializes back to null (preserving DriverInstance.ResilienceConfig = null).
|
||
/// Emits / reads the exact override JSON shape DriverResilienceOptionsParser consumes.
|
||
/// </summary>
|
||
public sealed class ResilienceFormModel
|
||
{
|
||
public static readonly string[] Capabilities =
|
||
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"];
|
||
|
||
public int? BulkheadMaxConcurrent { get; set; }
|
||
public int? BulkheadMaxQueue { get; set; }
|
||
public int? RecycleIntervalSeconds { get; set; }
|
||
|
||
// capability name -> (timeout, retry, breaker), each nullable.
|
||
public Dictionary<string, CapabilityRow> Policies { get; set; } =
|
||
Capabilities.ToDictionary(c => c, _ => new CapabilityRow());
|
||
|
||
public sealed class CapabilityRow
|
||
{
|
||
public int? TimeoutSeconds { get; set; }
|
||
public int? RetryCount { get; set; }
|
||
public int? BreakerFailureThreshold { get; set; }
|
||
public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null;
|
||
}
|
||
|
||
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
|
||
private static readonly JsonSerializerOptions WriteOpts = new()
|
||
{
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||
};
|
||
|
||
public static ResilienceFormModel FromJson(string? json)
|
||
{
|
||
var model = new ResilienceFormModel();
|
||
if (string.IsNullOrWhiteSpace(json)) return model;
|
||
|
||
Shape? shape;
|
||
try { shape = JsonSerializer.Deserialize<Shape>(json, ReadOpts); }
|
||
catch (JsonException) { return model; } // malformed -> empty form; raw view (Task 10) shows the text
|
||
if (shape is null) return model;
|
||
|
||
model.BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent;
|
||
model.BulkheadMaxQueue = shape.BulkheadMaxQueue;
|
||
model.RecycleIntervalSeconds = shape.RecycleIntervalSeconds;
|
||
if (shape.CapabilityPolicies is not null)
|
||
foreach (var (cap, p) in shape.CapabilityPolicies)
|
||
if (model.Policies.TryGetValue(cap, out var row))
|
||
{
|
||
row.TimeoutSeconds = p.TimeoutSeconds;
|
||
row.RetryCount = p.RetryCount;
|
||
row.BreakerFailureThreshold = p.BreakerFailureThreshold;
|
||
}
|
||
return model;
|
||
}
|
||
|
||
/// <summary>Emit only the non-null overrides; returns null when nothing is overridden.</summary>
|
||
public string? ToJson()
|
||
{
|
||
var caps = Policies
|
||
.Where(kv => !kv.Value.IsEmpty)
|
||
.ToDictionary(kv => kv.Key, kv => new PolicyShape
|
||
{
|
||
TimeoutSeconds = kv.Value.TimeoutSeconds,
|
||
RetryCount = kv.Value.RetryCount,
|
||
BreakerFailureThreshold = kv.Value.BreakerFailureThreshold,
|
||
});
|
||
|
||
var hasAny = BulkheadMaxConcurrent is not null || BulkheadMaxQueue is not null
|
||
|| RecycleIntervalSeconds is not null || caps.Count > 0;
|
||
if (!hasAny) return null;
|
||
|
||
var shape = new Shape
|
||
{
|
||
BulkheadMaxConcurrent = BulkheadMaxConcurrent,
|
||
BulkheadMaxQueue = BulkheadMaxQueue,
|
||
RecycleIntervalSeconds = RecycleIntervalSeconds,
|
||
CapabilityPolicies = caps.Count > 0 ? caps : null,
|
||
};
|
||
return JsonSerializer.Serialize(shape, WriteOpts);
|
||
}
|
||
|
||
private sealed class Shape
|
||
{
|
||
public int? BulkheadMaxConcurrent { get; set; }
|
||
public int? BulkheadMaxQueue { get; set; }
|
||
public int? RecycleIntervalSeconds { get; set; }
|
||
public Dictionary<string, PolicyShape>? CapabilityPolicies { get; set; }
|
||
}
|
||
|
||
private sealed class PolicyShape
|
||
{
|
||
public int? TimeoutSeconds { get; set; }
|
||
public int? RetryCount { get; set; }
|
||
public int? BreakerFailureThreshold { get; set; }
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Write the failing tests**
|
||
|
||
```csharp
|
||
using System.Text.Json;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||
|
||
public class ResilienceFormModelTests
|
||
{
|
||
[Fact]
|
||
public void Blank_form_serializes_to_null()
|
||
=> new ResilienceFormModel().ToJson().ShouldBeNull();
|
||
|
||
[Fact]
|
||
public void Partial_override_round_trips()
|
||
{
|
||
var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
|
||
m.Policies["Read"].TimeoutSeconds = 5;
|
||
m.Policies["Read"].RetryCount = 5;
|
||
|
||
var json = m.ToJson();
|
||
json.ShouldNotBeNull();
|
||
|
||
var back = ResilienceFormModel.FromJson(json);
|
||
back.BulkheadMaxConcurrent.ShouldBe(16);
|
||
back.Policies["Read"].TimeoutSeconds.ShouldBe(5);
|
||
back.Policies["Write"].IsEmpty.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact]
|
||
public void Emitted_json_is_consumable_by_the_runtime_parser()
|
||
{
|
||
var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
|
||
m.Policies["Read"].TimeoutSeconds = 7;
|
||
|
||
var opts = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, m.ToJson(), out var diag);
|
||
diag.ShouldBeNull();
|
||
opts.BulkheadMaxConcurrent.ShouldBe(16);
|
||
opts.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
|
||
// untouched capability keeps the tier-B default retry count
|
||
opts.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3: Run tests** — `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ResilienceFormModel"` → PASS.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs
|
||
git commit -m "feat(adminui): typed resilience override form model + tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Rewrite `DriverResilienceSection.razor` to the typed form
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Task 9)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor`
|
||
|
||
The component keeps its `[Parameter] string? ResilienceConfig` + `ResilienceConfigChanged` contract (so all 9 driver pages need NO change). Internally it parses to a `ResilienceFormModel`, binds typed fields, and on any change re-emits `ToJson()`. A collapsible "raw JSON" `<details>` keeps the escape hatch.
|
||
|
||
**Step 1: Replace the file body** (drop the stale "matches legacy DriverEdit" comment):
|
||
|
||
```razor
|
||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||
|
||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||
<div class="panel-head">Resilience overrides (optional)</div>
|
||
<div style="padding:1rem">
|
||
<p class="form-text mb-3">Blank fields use the driver type's stability-tier defaults
|
||
(see <span class="mono">docs/v2/driver-stability.md</span>). Set only what you need to override.</p>
|
||
|
||
<div class="row g-3">
|
||
<div class="col-md-4"><label class="form-label">Bulkhead max concurrent</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="_m.BulkheadMaxConcurrent" @bind:after="EmitAsync" placeholder="tier default" /></div>
|
||
<div class="col-md-4"><label class="form-label">Bulkhead max queue</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="_m.BulkheadMaxQueue" @bind:after="EmitAsync" placeholder="tier default" /></div>
|
||
<div class="col-md-4"><label class="form-label">Recycle interval (s, Tier C only)</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="_m.RecycleIntervalSeconds" @bind:after="EmitAsync" placeholder="none" /></div>
|
||
</div>
|
||
|
||
<div class="table-wrap mt-3">
|
||
<table class="data-table">
|
||
<thead><tr><th>Capability</th><th>Timeout (s)</th><th>Retries</th><th>Breaker threshold</th></tr></thead>
|
||
<tbody>
|
||
@foreach (var cap in ResilienceFormModel.Capabilities)
|
||
{
|
||
var row = _m.Policies[cap];
|
||
<tr>
|
||
<td class="mono">@cap</td>
|
||
<td><input type="number" class="form-control form-control-sm" @bind="row.TimeoutSeconds" @bind:after="EmitAsync" placeholder="default" /></td>
|
||
<td><input type="number" class="form-control form-control-sm" @bind="row.RetryCount" @bind:after="EmitAsync" placeholder="default" /></td>
|
||
<td><input type="number" class="form-control form-control-sm" @bind="row.BreakerFailureThreshold" @bind:after="EmitAsync" placeholder="default" /></td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<details class="mt-3">
|
||
<summary class="small text-muted">Raw JSON (advanced)</summary>
|
||
<pre class="form-control form-control-sm mono mt-2" style="white-space:pre-wrap;min-height:3rem;">@(_m.ToJson() ?? "(null — all tier defaults)")</pre>
|
||
</details>
|
||
</div>
|
||
</section>
|
||
|
||
@code {
|
||
[Parameter] public string? ResilienceConfig { get; set; }
|
||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||
|
||
private ResilienceFormModel _m = new();
|
||
private string? _lastParsed;
|
||
|
||
protected override void OnParametersSet()
|
||
{
|
||
// Re-parse only when the inbound value actually changed (avoid clobbering edits on re-render).
|
||
if (!string.Equals(_lastParsed, ResilienceConfig, StringComparison.Ordinal))
|
||
{
|
||
_m = ResilienceFormModel.FromJson(ResilienceConfig);
|
||
_lastParsed = ResilienceConfig;
|
||
}
|
||
}
|
||
|
||
private async Task EmitAsync()
|
||
{
|
||
var json = _m.ToJson();
|
||
_lastParsed = json;
|
||
ResilienceConfig = json;
|
||
await ResilienceConfigChanged.InvokeAsync(json);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: Build** — `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` → 0 errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor
|
||
git commit -m "feat(adminui): typed resilience override form replaces JSON textarea"
|
||
```
|
||
|
||
---
|
||
|
||
## WS3 — Editable DB-backed LDAP→role map (global)
|
||
|
||
### Task 11: `RoleMapper` DB-merge overload + tests
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Tasks 1-10, 12, 14, 16
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs`
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs`
|
||
|
||
**Semantics:** final roles = the appsettings/dev baseline (`result.Roles`) **∪** the system-wide DB grants. Additive (a group's DB grant adds to, never removes, the baseline). This preserves `DevStubMode`'s FleetAdmin and the empty-DB/DB-down case (baseline only). Cluster-scoped rows are ignored (global-only).
|
||
|
||
**Step 1: Add the overload**
|
||
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||
|
||
// …existing Map(groups, dict) stays…
|
||
|
||
/// <summary>
|
||
/// Merge the appsettings-derived baseline roles with system-wide DB grants. DB rows are
|
||
/// additive; cluster-scoped rows (IsSystemWide == false) are ignored under the global model.
|
||
/// </summary>
|
||
/// <param name="baselineRoles">Roles already resolved from appsettings (or the dev stub).</param>
|
||
/// <param name="dbRows">LdapGroupRoleMapping rows for the user's groups (from GetByGroupsAsync).</param>
|
||
public static IReadOnlyList<string> Merge(
|
||
IReadOnlyCollection<string> baselineRoles,
|
||
IReadOnlyCollection<LdapGroupRoleMapping> dbRows)
|
||
{
|
||
var roles = new HashSet<string>(baselineRoles, StringComparer.OrdinalIgnoreCase);
|
||
foreach (var row in dbRows)
|
||
if (row.IsSystemWide)
|
||
roles.Add(row.Role.ToString());
|
||
return [.. roles];
|
||
}
|
||
```
|
||
|
||
**Step 2: Write the failing tests**
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Merge_unions_baseline_and_systemwide_db_roles()
|
||
{
|
||
var rows = new[]
|
||
{
|
||
new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true },
|
||
new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" },
|
||
};
|
||
var result = RoleMapper.Merge(["ConfigViewer"], rows);
|
||
result.ShouldContain("ConfigViewer");
|
||
result.ShouldContain("FleetAdmin");
|
||
result.ShouldNotContain("ConfigEditor"); // cluster-scoped row ignored (global-only)
|
||
}
|
||
|
||
[Fact]
|
||
public void Merge_with_no_db_rows_returns_baseline()
|
||
=> RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]);
|
||
```
|
||
|
||
(Add `using ZB.MOM.WW.OtOpcUa.Configuration.Entities;` and `…Enums;`.)
|
||
|
||
**Step 3: Run** — `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter "FullyQualifiedName~RoleMapper"` → PASS.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs
|
||
git commit -m "feat(security): RoleMapper.Merge — additive DB-backed role grants"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Register `ILdapGroupRoleMappingService` in DI
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Tasks 1-11, 14, 16
|
||
|
||
**Files:**
|
||
- Modify: the composition root(s) where `OtOpcUaConfigDbContext`/config services are registered. Find with:
|
||
`grep -rIn "AddDbContextFactory<OtOpcUaConfigDbContext>\|AddOtOpcUa\|AddConfigurationServices" src/Server --include='*.cs' | grep -v /bin/`
|
||
Likely an `AddConfiguration…`/`ServiceCollectionExtensions` in `ZB.MOM.WW.OtOpcUa.Configuration` or the AdminUI/Host `Program.cs`.
|
||
|
||
**Step 1:** Register the scoped service next to the config DbContext registration (both the Admin UI process and the auth/login host need it):
|
||
|
||
```csharp
|
||
services.AddScoped<ILdapGroupRoleMappingService, LdapGroupRoleMappingService>();
|
||
```
|
||
|
||
(Add `using ZB.MOM.WW.OtOpcUa.Configuration.Services;`.)
|
||
|
||
**Step 2: Build** the solution → 0 errors.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git commit -am "chore(di): register ILdapGroupRoleMappingService"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Wire DB merge into `AuthEndpoints.LoginAsync`
|
||
|
||
**Classification:** high-risk (auth path)
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Tasks 11, 12)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs`
|
||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` (keep green; add a DB-backed-role case if the fixture supports it).
|
||
|
||
**Step 1: Inject the service and merge before building claims.** Change the `LoginAsync` signature to add the service, and replace the role-claim loop.
|
||
|
||
Signature:
|
||
```csharp
|
||
private static async Task<IResult> LoginAsync(
|
||
HttpContext http,
|
||
ILdapAuthService ldap,
|
||
ILdapGroupRoleMappingService roleMappings,
|
||
CancellationToken ct)
|
||
```
|
||
|
||
After the `if (!result.Success)` block and before building `claims`, compute merged roles:
|
||
```csharp
|
||
IReadOnlyList<string> roles = result.Roles;
|
||
try
|
||
{
|
||
var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
|
||
roles = RoleMapper.Merge(result.Roles, dbRows);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// DB hiccup must never block sign-in — fall back to the appsettings baseline.
|
||
http.RequestServices.GetService<ILoggerFactory>()?
|
||
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
||
.LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline", username);
|
||
}
|
||
```
|
||
Then change the claim loop to iterate `roles` instead of `result.Roles`:
|
||
```csharp
|
||
foreach (var role in roles)
|
||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||
```
|
||
(Add `using Microsoft.Extensions.DependencyInjection;` and `using Microsoft.Extensions.Logging;` and `using ZB.MOM.WW.OtOpcUa.Configuration.Services;`.)
|
||
|
||
Confirm `LdapAuthResult` exposes `Groups` (it does: positional record `(Success, DisplayName, Username, Groups, Roles, Error)`).
|
||
|
||
**Step 2: Build** the solution → 0 errors.
|
||
|
||
**Step 3: Run the security tests**
|
||
|
||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests`
|
||
Expected: all PASS (existing login/logout integration tests still green — the minimal-API handler resolves the new scoped service from the test host's DI; if the test host doesn't register it, register it in that fixture).
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||
git commit -m "feat(security): merge DB-backed LDAP role grants into login claims"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Add `FleetAdmin` authorization policy
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~2 min
|
||
**Parallelizable with:** Tasks 1-13, 16
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:90` (inside the `AddAuthorization` lambda, beside the `DriverOperator` policy)
|
||
|
||
**Step 1:** Add:
|
||
```csharp
|
||
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin"));
|
||
```
|
||
|
||
**Step 2: Build** → 0 errors.
|
||
|
||
**Step 3: Commit** — `git commit -am "feat(security): add FleetAdmin authorization policy"`
|
||
|
||
---
|
||
|
||
### Task 15: Rewrite `RoleGrants.razor` as global CRUD (FleetAdmin-gated)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Tasks 12, 14)
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor`
|
||
|
||
Page keeps the LDAP-binding card (from `IOptionsSnapshot<LdapOptions>`) and shows the appsettings `GroupToRole` map read-only as "fallback (appsettings)". Adds a DB-backed editable table over `ILdapGroupRoleMappingService` (system-wide rows only). Gate the whole page to FleetAdmin.
|
||
|
||
**Step 1: Replace the file.** Drop the stale "deferred" comment + notice. Set `@attribute [Authorize(Policy = "FleetAdmin")]`. Inject `IDbContextFactory`? No — inject `ILdapGroupRoleMappingService` directly.
|
||
|
||
Key `@code` shape (full markup mirrors existing cards/tables + a small add-row form + a CollectionEditor-free inline table with Delete; add is a 2-field form: group + AdminRole):
|
||
|
||
```razor
|
||
@page "/role-grants"
|
||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")]
|
||
@rendermode RenderMode.InteractiveServer
|
||
@using Microsoft.Extensions.Options
|
||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
||
@using ZB.MOM.WW.OtOpcUa.Security.Ldap
|
||
@inject IOptionsSnapshot<LdapOptions> Ldap
|
||
@inject ILdapGroupRoleMappingService RoleMappings
|
||
|
||
@code {
|
||
private LdapOptions? _options;
|
||
private IReadOnlyList<LdapGroupRoleMapping> _rows = [];
|
||
private string _newGroup = "";
|
||
private AdminRole _newRole = AdminRole.ConfigViewer;
|
||
private string? _error;
|
||
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
_options = Ldap.Value;
|
||
await ReloadAsync();
|
||
}
|
||
|
||
private async Task ReloadAsync()
|
||
=> _rows = (await RoleMappings.ListAllAsync(default)).Where(r => r.IsSystemWide).ToList();
|
||
|
||
private async Task AddAsync()
|
||
{
|
||
_error = null;
|
||
if (string.IsNullOrWhiteSpace(_newGroup)) { _error = "LDAP group is required."; return; }
|
||
try
|
||
{
|
||
await RoleMappings.CreateAsync(new LdapGroupRoleMapping
|
||
{
|
||
LdapGroup = _newGroup.Trim(), Role = _newRole, IsSystemWide = true, ClusterId = null,
|
||
}, default);
|
||
_newGroup = "";
|
||
await ReloadAsync();
|
||
}
|
||
catch (Exception ex) { _error = ex.Message; } // e.g. duplicate (group) unique-index violation
|
||
}
|
||
|
||
private async Task DeleteAsync(Guid id)
|
||
{
|
||
_error = null;
|
||
try { await RoleMappings.DeleteAsync(id, default); await ReloadAsync(); }
|
||
catch (Exception ex) { _error = ex.Message; }
|
||
}
|
||
}
|
||
```
|
||
|
||
Markup: keep the existing "LDAP binding" card; add a panel "Group → role (database)" with an add-row form (`<input @bind="_newGroup">`, `<select @bind="_newRole">` over `Enum.GetValues<AdminRole>()`, `<button @onclick="AddAsync">Add</button>`), a table of `_rows` (LdapGroup, Role, Delete button), and `_error` display; and a separate read-only panel "Fallback (appsettings)" rendering `_options.GroupToRole` as today (label it as fallback used when a group has no database row).
|
||
|
||
**Step 2: Build** the AdminUI project → 0 errors. Confirm the page resolves `ILdapGroupRoleMappingService` (registered in Task 12).
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor
|
||
git commit -m "feat(adminui): editable DB-backed LDAP role map (global, FleetAdmin-gated)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: Extend `LdapGroupRoleMappingServiceTests` for global CRUD
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Tasks 1-14
|
||
|
||
**Files:**
|
||
- Modify: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs`
|
||
|
||
**Step 1:** Add tests using the file's existing in-memory EF setup pattern:
|
||
- `CreateAsync` a system-wide row → `ListAllAsync` returns it.
|
||
- `CreateAsync` with `IsSystemWide=true` + non-null `ClusterId` → throws `InvalidLdapGroupRoleMappingException`.
|
||
- `DeleteAsync` removes the row.
|
||
|
||
(If a uniqueness test against a relational provider is needed, follow the existing fixture; in-memory EF won't enforce the unique index, so duplicate-rejection is asserted via the SQL fixture if the file already uses one — otherwise leave duplicate handling to the page's catch.)
|
||
|
||
**Step 2: Run** — `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests --filter "FullyQualifiedName~LdapGroupRoleMapping"` → PASS.
|
||
|
||
**Step 3: Commit** — `git commit -am "test(config): global LdapGroupRoleMapping CRUD"`
|
||
|
||
---
|
||
|
||
## WS4 — Cleanup (runs last, after features land)
|
||
|
||
### Task 17: Delete stale source comments
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 18
|
||
**Depends on:** Tasks 2-8, 10, 15 (features must exist first)
|
||
|
||
**Files (delete/clean the stale comment only — verify each is truly obsolete before editing):**
|
||
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusHub.cs:10-12` — remove "passive channel / until the bridge lands" (bridge `FleetStatusSignalRBridge` is wired).
|
||
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:~15-23` — remove the "follow-up F15 / 47 .razor files" paragraph; keep an accurate one-liner.
|
||
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor:1` — drop "generic DriverEdit page … (Phase 4)"; keep "shared identity section".
|
||
- `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor:2-5` and `DriverTypePicker.razor:1-3` — remove `TODO(3.3/3.4)` + "falls back to legacy DriverEdit" references (legacy file is gone; verify the router's fallback branch and clean it if it points nowhere).
|
||
|
||
**Step 1:** Edit each comment. **Step 2:** `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` → 0 errors. **Step 3:** `git commit -am "docs(adminui): remove stale follow-up source comments (F15/F16/Phase4/TODO)"`
|
||
|
||
---
|
||
|
||
### Task 18: Strip now-true rendered notes
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 17
|
||
**Depends on:** Tasks 2-8, 15
|
||
|
||
**Files (remove the rendered note paragraph; the feature it described now exists):**
|
||
- Per-driver pages — the "list-editor coming in a follow-up phase" `<p class="form-text">` notes (Modbus/AbCip/AbLegacy/TwinCAT/S7/FOCAS, replaced by the editor in WS1; remove any leftover note line).
|
||
- `OpcUaClientDriverPage.razor` — the "Endpoint URLs list — read-only JSON view (full list-editor is a follow-up)" note.
|
||
- `RoleGrants.razor` — already removed in Task 15 (verify none remains).
|
||
- `DriverResilienceSection.razor` — already removed in Task 10 (verify).
|
||
|
||
Confirm clean: `grep -rIn -iE "follow-up phase|list-editor|coming in a follow|read-only JSON view" src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components` returns nothing in rendered markup.
|
||
|
||
**Step 1:** Edit. **Step 2:** Build AdminUI → 0 errors. **Step 3:** `git commit -am "docs(adminui): strip rendered follow-up notes now that editors ship"`
|
||
|
||
---
|
||
|
||
### Task 19: Full verification
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min (plus `/run`)
|
||
**Depends on:** all tasks
|
||
|
||
**Step 1:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 errors.
|
||
**Step 2:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → all green (note any pre-existing unrelated failures).
|
||
**Step 3:** `/run` the AdminUI and manually verify: add/edit/delete a Modbus tag and one device-driver's device; set a resilience override + confirm raw-JSON reflects it; add + delete a RoleGrants row (as FleetAdmin) and confirm a non-FleetAdmin is blocked.
|
||
**Step 4:** Use superpowers-extended-cc:finishing-a-development-branch to decide merge/PR.
|
||
|
||
---
|
||
|
||
## Task dependency summary
|
||
|
||
- Task 1 → blocks 2,3,4,5,6,7,8
|
||
- Task 9 → blocks 10
|
||
- Tasks 11,12 → block 13
|
||
- Tasks 12,14 → block 15
|
||
- Tasks 2-8,10,15 → block 17,18
|
||
- All → block 19
|
||
- Parallel pools: {1,9,11,12,14,16} can start immediately; {2-8} after 1; {3-8} parallel with each other.
|