19 tasks across WS1 (driver collection editors), WS2 (typed resilience form), WS3 (editable DB-backed LDAP role map, global), WS4 (cleanup).
46 KiB
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
DriverConfigJSON and ARE the runtime source of truth (driver factories deserialize and poll them). The canonicalTagtable 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.LoginAsyncbuilds role claims fromresult.Roles(whichLdapAuthServicecomputes from appsettingsGroupToRole).ILdapGroupRoleMappingService(CRUD overLdapGroupRoleMapping,AdminRoleenum: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, fallbackRequireAuthenticatedUser, existingDriverOperatorpolicy). - 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.slnxafter 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
@* 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
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:
// 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:
private IReadOnlyList<ModbusTagDefinition> _tags = [];
private string _tagsJson = "[]";
with:
private List<ModbusTagRow> _tags = [];
In OnInitializedAsync, replace _tags = opts.Tags; with:
_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:
_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:
<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:
[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
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/fromstring; validate non-empty + well-formedopc.tcp://. LeaveUnsMappingTable'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
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
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
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):
@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
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
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
[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
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 anAddConfiguration…/ServiceCollectionExtensionsinZB.MOM.WW.OtOpcUa.Configurationor the AdminUI/HostProgram.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):
services.AddScoped<ILdapGroupRoleMappingService, LdapGroupRoleMappingService>();
(Add using ZB.MOM.WW.OtOpcUa.Configuration.Services;.)
Step 2: Build the solution → 0 errors.
Step 3: Commit
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:
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:
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:
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
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 theAddAuthorizationlambda, beside theDriverOperatorpolicy)
Step 1: Add:
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):
@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
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:
CreateAsynca system-wide row →ListAllAsyncreturns it.CreateAsyncwithIsSystemWide=true+ non-nullClusterId→ throwsInvalidLdapGroupRoleMappingException.DeleteAsyncremoves 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" (bridgeFleetStatusSignalRBridgeis 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-5andDriverTypePicker.razor:1-3— removeTODO(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.