25 Commits

Author SHA1 Message Date
Joseph Doherty a8916c3e08 docs(adminui): correct stale follow-up source comments (F15/F16/Phase4/TODO 3.3-3.4)
v2-ci / build (push) Failing after 46s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-05-29 10:00:58 -04:00
Joseph Doherty 79b2345834 fix(adminui): disable RoleGrants buttons during save (review) 2026-05-29 09:58:05 -04:00
Joseph Doherty 4df5b849ac fix(security): let OperationCanceledException propagate from login role merge (review) 2026-05-29 09:56:09 -04:00
Joseph Doherty a58151e99e feat(adminui): editable DB-backed LDAP role map (global, FleetAdmin-gated) 2026-05-29 09:55:07 -04:00
Joseph Doherty 1fd093d95d test(config): global LdapGroupRoleMapping CRUD 2026-05-29 09:52:47 -04:00
Joseph Doherty f210f09caf feat(security): merge DB-backed LDAP role grants into login claims 2026-05-29 09:51:22 -04:00
Joseph Doherty 042f3b6a65 feat(security): add FleetAdmin authorization policy 2026-05-29 09:48:31 -04:00
Joseph Doherty bc40388914 chore(di): register ILdapGroupRoleMappingService 2026-05-29 09:47:10 -04:00
Joseph Doherty b719194046 feat(security): RoleMapper.Merge — additive DB-backed role grants 2026-05-29 09:43:12 -04:00
Joseph Doherty 7570df76d3 feat(adminui): editable OpcUaClient endpoint URL list via CollectionEditor 2026-05-29 09:41:09 -04:00
Joseph Doherty 244949caa3 feat(adminui): editable S7 tag list via CollectionEditor 2026-05-29 09:37:12 -04:00
Joseph Doherty a5a0d06dbe feat(adminui): editable FOCAS device + tag lists via CollectionEditor 2026-05-29 09:33:53 -04:00
Joseph Doherty 6882761f4c feat(adminui): editable TwinCAT device + tag lists via CollectionEditor 2026-05-29 09:29:57 -04:00
Joseph Doherty 15f3797f1e feat(adminui): editable AbLegacy device + tag lists via CollectionEditor 2026-05-29 09:26:25 -04:00
Joseph Doherty 534d670b21 feat(adminui): editable AbCip device + tag lists via CollectionEditor 2026-05-29 09:22:51 -04:00
Joseph Doherty b351a81c8f fix(adminui): preserve un-edited Modbus tag fields across edit (review)
Capture the original ModbusTagDefinition as _source in ModbusTagRow and
rewrite ToDefinition() to use 'with {}', so StringByteOrder, ArrayCount,
Deadband, UnitId, and CoalesceProhibited survive a load→edit→save cycle.
2026-05-29 09:18:36 -04:00
Joseph Doherty f655efc570 feat(adminui): typed resilience override form replaces JSON textarea 2026-05-29 09:15:54 -04:00
Joseph Doherty c4116e54c9 feat(adminui): editable Modbus tag list via CollectionEditor 2026-05-29 09:14:06 -04:00
Joseph Doherty c3fec1426c fix(adminui): case-insensitive resilience policy keys + malformed-json test (review) 2026-05-29 09:10:41 -04:00
Joseph Doherty a2761e4b98 fix(adminui): key CollectionEditor rows by identity (code review) 2026-05-29 09:08:02 -04:00
Joseph Doherty 4a469fbe06 feat(adminui): typed resilience override form model + tests 2026-05-29 09:06:45 -04:00
Joseph Doherty e2fa6754bb feat(adminui): add generic CollectionEditor<TRow> modal list editor 2026-05-29 09:03:03 -04:00
Joseph Doherty b76561a780 docs(adminui): implementation plan + task persistence for deferred follow-ups
19 tasks across WS1 (driver collection editors), WS2 (typed resilience
form), WS3 (editable DB-backed LDAP role map, global), WS4 (cleanup).
2026-05-29 08:59:55 -04:00
Joseph Doherty c49fccbe0c docs(adminui): design for completing deferred follow-ups
Driver collection editors (modal-per-row shared shell), resilience typed
form, editable DB-backed LDAP->role map (global roles, live on next
sign-in), and stale-comment/note cleanup. Roles intentionally global —
no per-cluster permissions.
2026-05-29 08:45:50 -04:00
Joseph Doherty 5622e51006 fix(adminui): clean up dev-migration note on Home page
Removed the F15 follow-up annotation that was visible to end users.
Replaced with a one-line orientation pointer to the nav.
2026-05-29 08:02:57 -04:00
36 changed files with 3450 additions and 364 deletions
@@ -0,0 +1,132 @@
# Design — Complete AdminUI deferred follow-ups
**Date:** 2026-05-29
**Status:** Approved (design); implementation plan to follow
**Author:** Joseph Doherty (with Claude Code)
## Background
The AdminUI carried a family of "deferred / Phase C.2 follow-up" notes. A prior
change stripped the stale *rendered roadmap banners* from the cluster list pages.
Three remaining note groups were investigated to decide what real work they hide:
- **Group 1 — driver-page inline notes** ("list-editor coming in a follow-up
phase" for tags/devices/endpoints; "typed-form-ifying Polly is a follow-up").
**Real pending UI work.**
- **Group 2 — RoleGrants** ("UI-driven editing of the mapping is deferred — it
implies a config-reload mechanism that doesn't exist yet"). → **Real work; half
the infra already exists.**
- **Group 3 — source comments** (F15 Razor migration, F16 FleetStatusHub bridge,
"Phase 4" identity section, `TODO(3.3/3.4)` route collision). → **~90% stale**;
the referenced work already shipped (the F16 bridge is wired; the legacy
`DriverEdit.razor` no longer exists). Only the Polly typed form is real, and it
is already counted in Group 1.
### Key facts established during exploration
- **Driver-embedded tag/device lists in `DriverConfig` JSON are the runtime source
of truth.** Driver factories deserialize them and poll exactly those rows; the
canonical `Tag` table is orthogonal (OPC UA browse-tree only, never read by
drivers). So inline editors are meaningful, not redundant — editing them changes
what the driver polls on the next publish/reinitialize.
- **Resilience** already has a strongly-typed model: `DriverResilienceOptions`
(`BulkheadMaxConcurrent`, `BulkheadMaxQueue`, `RecycleIntervalSeconds`,
`CapabilityPolicies: {DriverCapability → (TimeoutSeconds, RetryCount,
BreakerFailureThreshold)}`) with tier A/B/C defaults via `GetTierDefaults(tier)`
and a `DriverResilienceOptionsParser`. The stored JSON is an *override* shape;
null/absent keys fall back to tier defaults.
- **LDAP role map**: the `LdapGroupRoleMapping` entity + migration +
`ILdapGroupRoleMappingService` (CRUD) already exist but are **not wired** into
login. `LdapAuthService` still reads the static appsettings `GroupToRole`
(`Dictionary<string,string>`). `RoleGrants.razor` is read-only.
- **Testing**: no bUnit. Established pattern = test `FromOptions`/`ToOptions`
round-trips (xUnit + Shouldly in `AdminUI.Tests`) and services with in-memory EF
(`Configuration.Tests`).
## Decisions
- **Scope:** full build — all real follow-ups in Groups 1 & 2, plus Group 3
comment cleanup.
- **List-editor UX:** modal-per-row with a shared shell component.
- **LDAP reload semantics:** DB-backed, **live on the user's next sign-in**
(per-login DB query; no restart, no new infra). appsettings `GroupToRole` becomes
a bootstrap **fallback** layer.
- **Roles are GLOBAL.** No cluster-level permissions / no per-cluster enforcement
(explicitly chosen for simplicity, reversing an earlier cluster-scoping answer).
Every `LdapGroupRoleMapping` row is `IsSystemWide=true`, `ClusterId=null`.
## Workstreams
### WS1 — Driver collection editors (modal-per-row + shared shell)
- New generic `CollectionEditor<TRow>` component in `Components/Shared/Drivers/`:
compact read-only table + `[+ Add]` / per-row `Edit` / `Delete`, and a Bootstrap
modal editing a **working copy** of a row (commit on modal-Save, discard on
Cancel). Parameters: `List<TRow> Items` (bound), header fragment, read-only-cells
fragment, modal-body fragment, `NewRow` factory, optional `Validate` delegate.
- Each driver page swaps its read-only `<pre>` for a `CollectionEditor` supplying
its own columns + modal fields. Edits mutate the in-memory `List<T>` already in
the page's `FormModel`; the page's existing **Save** serializes it into
`DriverConfig` — no new persistence path.
- Coverage: tags (Modbus, AbCip, AbLegacy, TwinCAT, S7, FOCAS); devices (AbCip,
AbLegacy, TwinCAT, FOCAS); endpoints (OpcUaClient).
- **Errors/validation:** required fields, duplicate Name within list,
driver-specific address format; delete confirm; list mutates only on valid commit.
- **Testing:** per-driver `NewRow` factories + `Validate` methods unit-tested
directly; existing `*FormSerializationTests` extended for add/remove via the form
model. Modal interaction verified manually via `/run`.
### WS2 — Resilience typed form
- Replace the textarea in `DriverResilienceSection.razor` with a typed form bound to
a new mutable `ResilienceFormModel` (all fields nullable; null = tier default):
bulkhead concurrent/queue, recycle interval, and an 8-capability grid (Read,
Write, Discover, Subscribe, Probe, AlarmSubscribe, AlarmAcknowledge, HistoryRead)
of (timeout / retry / breaker-threshold).
- `FromJson`/`ToJson` emit only non-null overrides (blank → `null`, preserving the
current "null = tier defaults" contract). The section gains a `DriverTier`
parameter; each driver page passes its known tier so `GetTierDefaults(tier)`
renders as placeholders. A collapsible "raw JSON" view remains as escape hatch.
- **Errors:** non-negative / sane-range numeric validation; emitted JSON must
re-parse cleanly through `DriverResilienceOptionsParser`.
- **Testing:** `ResilienceFormModel` round-trip tests in `AdminUI.Tests`
blank→null, partial-override-preserved, emit→parse-back compatibility.
### WS3 — Editable LDAP→role map (DB-backed, global, live on next sign-in)
- `RoleGrants.razor` → full CRUD over `LdapGroupRoleMapping` via the existing
`ILdapGroupRoleMappingService`. **Global only**: `IsSystemWide=true`,
`ClusterId=null`; no cluster UI. Fields: LDAP group, `AdminRole`
(ConfigViewer/ConfigEditor/FleetAdmin), notes. A group may carry several roles
(multiple rows). Edit page gated to **FleetAdmin** (add a minimal FleetAdmin
authorization policy; confirm existing role-policy plumbing during plan-writing).
- Wire the service into `LdapAuthService`: at login → resolve groups →
`GetByGroupsAsync` (indexed) → map roles → **merge appsettings `GroupToRole` as a
fallback layer** (used when no DB row covers a group). Edits take effect on the
user's next sign-in. DB rows authoritative + editable; appsettings entries shown
read-only as "fallback."
- **Errors:** DB unreachable at login → catch, log, fall back to appsettings;
login never blocks. CRUD: no duplicate `(LdapGroup, Role)`; group/role required.
- **Testing:** extend `LdapGroupRoleMappingServiceTests` (in-memory EF) for CRUD +
dedupe; new `RoleMapper` overload `Map(groups, dbRows, fallbackDict)` unit-tested
for merge + fallback precedence + DB-error fallback.
### WS4 — Cleanup (runs last, after the features exist)
- **Delete stale comments:** `FleetStatusHub.cs` ("passive channel / until the
bridge lands"), `EndpointRouteBuilderExtensions.cs` (F15), `DriverIdentitySection.razor`
("Phase 4 / generic DriverEdit"), `DriverEditRouter.razor` + `DriverTypePicker.razor`
(`TODO(3.3/3.4)` + the "falls back to legacy DriverEdit" path — verify & clean,
legacy file is gone), and update `DriverResilienceSection.razor`'s comment.
- **Strip rendered notes** now true: per-driver "list-editor coming in a follow-up
phase" notes, the OpcUaClient endpoint note, the resilience "typed-form-ifying
Polly is a follow-up" note, and the RoleGrants "UI-driven editing is deferred" note.
## Cross-cutting
- **No DB schema change** — `LdapGroupRoleMapping` migration already applied;
`DriverConfig`/`ResilienceConfig` columns unchanged.
- **Definition of done:** build clean + `dotnet test` green + a `/run` pass
exercising the modal editors and role-map CRUD.
- **Suggested sequence:** WS1 shared shell + Modbus tags as proof → remaining
drivers → WS2 → WS3 → WS4.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
{
"planPath": "docs/plans/2026-05-29-adminui-followups.md",
"branch": "feat/adminui-followups",
"tasks": [
{"id": 11, "plan": 1, "subject": "Task 1: Generic CollectionEditor<TRow> component", "status": "pending"},
{"id": 12, "plan": 2, "subject": "Task 2: Modbus tag editor (proof) + tests", "status": "pending", "blockedBy": [11]},
{"id": 13, "plan": 3, "subject": "Task 3: AbCip device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 14, "plan": 4, "subject": "Task 4: AbLegacy device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 15, "plan": 5, "subject": "Task 5: TwinCAT device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 16, "plan": 6, "subject": "Task 6: FOCAS device+tag editors + tests", "status": "pending", "blockedBy": [11]},
{"id": 17, "plan": 7, "subject": "Task 7: S7 tag editor + tests", "status": "pending", "blockedBy": [11]},
{"id": 18, "plan": 8, "subject": "Task 8: OpcUaClient endpoint-URL editor + tests", "status": "pending", "blockedBy": [11]},
{"id": 19, "plan": 9, "subject": "Task 9: ResilienceFormModel + tests", "status": "pending"},
{"id": 20, "plan": 10, "subject": "Task 10: Typed resilience form in DriverResilienceSection", "status": "pending", "blockedBy": [19]},
{"id": 21, "plan": 11, "subject": "Task 11: RoleMapper.Merge overload + tests", "status": "pending"},
{"id": 22, "plan": 12, "subject": "Task 12: Register ILdapGroupRoleMappingService in DI", "status": "pending"},
{"id": 23, "plan": 13, "subject": "Task 13: Wire DB merge into AuthEndpoints.LoginAsync", "status": "pending", "blockedBy": [21, 22]},
{"id": 24, "plan": 14, "subject": "Task 14: Add FleetAdmin authorization policy", "status": "pending"},
{"id": 25, "plan": 15, "subject": "Task 15: RoleGrants.razor global CRUD (FleetAdmin-gated)", "status": "pending", "blockedBy": [22, 24]},
{"id": 26, "plan": 16, "subject": "Task 16: LdapGroupRoleMapping service tests (global CRUD)", "status": "pending"},
{"id": 27, "plan": 17, "subject": "Task 17: Delete stale source comments", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 20, 25]},
{"id": 28, "plan": 18, "subject": "Task 18: Strip now-true rendered notes", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 25]},
{"id": 29, "plan": 19, "subject": "Task 19: Full verification (build + test + /run)", "status": "pending", "blockedBy": [20, 23, 26, 27, 28]}
],
"lastUpdated": "2026-05-29"
}
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
namespace ZB.MOM.WW.OtOpcUa.Configuration;
@@ -22,6 +23,16 @@ public static class ServiceCollectionExtensions
$"Connection string '{ConnectionStringName}' is required. Add it to appsettings.json or the OTOPCUA_CONFIG_CONNECTION env var.");
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseSqlServer(connectionString));
// AddDbContextFactory registers only the IDbContextFactory<> — it does NOT also register
// a scoped OtOpcUaConfigDbContext. Config services that take the context directly (e.g.
// LdapGroupRoleMappingService) need a scoped instance, so bridge one off the factory.
services.AddScoped(sp => sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContext());
// Config-DB services consumed by both the AdminUI (RoleGrants page) and the auth/login
// host (AuthEndpoints.LoginAsync). Scoped to match the request/render scope of both callers.
services.AddScoped<ILdapGroupRoleMappingService, LdapGroupRoleMappingService>();
return services;
}
}
@@ -146,27 +146,65 @@ else
</div>
</section>
@* Devices — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Devices</div>
<div style="padding:1rem">
<p class="form-text mb-2">
Device list (host addresses, PLC family, packing overrides) — full list-editor coming in a follow-up phase. Each entry: <code>{ "hostAddress": "ab://gateway/1,0", "plcFamily": "ControlLogix" }</code>.
</p>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_devicesJson</pre>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="AbCipDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".12s"
NewRow="@(() => new AbCipDeviceRow())" Clone="@(r => r.Clone())"
Validate="AbCipDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="ab://gateway/1,0" /></div>
<div class="col-md-3"><label class="form-label">PLC family</label>
<select class="form-select form-select-sm" @bind="d.PlcFamily">
@foreach (var e in Enum.GetValues<AbCipPlcFamily>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<p class="form-text mb-2">
Tag list — full list-editor coming in a follow-up phase. Edit via the Tag editor pages or export/import the driver config JSON.
</p>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_tagsJson</pre>
</div>
</section>
@* Tags *@
<CollectionEditor TRow="AbCipTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".14s"
NewRow="@(() => new AbCipTagRow())" Clone="@(r => r.Clone())"
Validate="AbCipTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Tag path</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.TagPath</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-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="ab://gateway/1,0" /></div>
<div class="col-md-6"><label class="form-label">Tag path</label>
<input class="form-control form-control-sm mono" @bind="t.TagPath"
placeholder="e.g. Program:Main.SomeTag" /></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<AbCipDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -202,11 +240,9 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Collections are preserved through round-trip and shown as read-only JSON.
private IReadOnlyList<AbCipDeviceOptions> _devices = [];
private IReadOnlyList<AbCipTagDefinition> _tags = [];
private string _devicesJson = "[]";
private string _tagsJson = "[]";
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<AbCipDeviceRow> _devices = [];
private List<AbCipTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
@@ -246,12 +282,10 @@ else
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices;
_tags = opts.Tags;
_devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(AbCipTagRow.FromDefinition).ToList();
}
}
_devicesJson = System.Text.Json.JsonSerializer.Serialize(_devices, _jsonOpts);
_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);
_loaded = true;
}
@@ -260,7 +294,11 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts);
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -331,7 +369,11 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbCipDriverOptions? TryDeserialize(string json)
{
@@ -339,6 +381,91 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — AbCipDeviceOptions is an immutable record.
public sealed class AbCipDeviceRow
{
public string HostAddress { get; set; } = "";
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (AllowPacking, ConnectionSize) across a load→save.
private AbCipDeviceOptions? _source;
public AbCipDeviceRow Clone() => (AbCipDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbCipDeviceRow FromDefinition(AbCipDeviceOptions d) => new()
{
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
_source = d,
};
public AbCipDeviceOptions ToDefinition()
{
var baseDef = _source ?? new AbCipDeviceOptions(HostAddress.Trim(), PlcFamily);
return baseDef with
{
HostAddress = HostAddress.Trim(),
PlcFamily = PlcFamily,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(AbCipDeviceRow row, IReadOnlyList<AbCipDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Mutable VM for the modal editor — AbCipTagDefinition is an immutable record.
public sealed class AbCipTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string TagPath { get; set; } = "";
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent, Members, SafetyTag) across a load→save.
private AbCipTagDefinition? _source;
public AbCipTagRow Clone() => (AbCipTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbCipTagRow FromDefinition(AbCipTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, TagPath = d.TagPath,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public AbCipTagDefinition ToDefinition()
{
var baseDef = _source ?? new AbCipTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), TagPath.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
TagPath = TagPath.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(AbCipTagRow row, IReadOnlyList<AbCipTagRow> 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;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
@@ -112,30 +112,65 @@ else
</div>
</section>
@* Devices — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Devices</div>
<div style="padding:1rem">
<p class="form-text mb-2">
Device list (host addresses, PLC family) — full list-editor coming in a follow-up phase.
Each entry: <code>{ "hostAddress": "...", "plcFamily": "Slc500" }</code>.
PLC families: <code>Slc500</code>, <code>MicroLogix</code>, <code>Plc5</code>, <code>LogixPccc</code>.
</p>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_devicesJson</pre>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="AbLegacyDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".10s"
NewRow="@(() => new AbLegacyDeviceRow())" Clone="@(r => r.Clone())"
Validate="AbLegacyDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="10.0.0.10" /></div>
<div class="col-md-3"><label class="form-label">PLC family</label>
<select class="form-select form-select-sm" @bind="d.PlcFamily">
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<p class="form-text mb-2">
Tag list — full list-editor coming in a follow-up phase. Edit via the Tag editor pages or export/import the driver config JSON.
Each tag has a PCCC file address (e.g. <code>N7:0</code>, <code>F8:0</code>, <code>B3:0/0</code>).
</p>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_tagsJson</pre>
</div>
</section>
@* Tags *@
<CollectionEditor TRow="AbLegacyTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".12s"
NewRow="@(() => new AbLegacyTagRow())" Clone="@(r => r.Clone())"
Validate="AbLegacyTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</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-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="10.0.0.10" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. N7:0, F8:0, B3:0/0" /></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<AbLegacyDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -171,11 +206,9 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Collections are preserved through round-trip and shown as read-only JSON.
private IReadOnlyList<AbLegacyDeviceOptions> _devices = [];
private IReadOnlyList<AbLegacyTagDefinition> _tags = [];
private string _devicesJson = "[]";
private string _tagsJson = "[]";
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<AbLegacyDeviceRow> _devices = [];
private List<AbLegacyTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
@@ -215,12 +248,10 @@ else
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices;
_tags = opts.Tags;
_devices = opts.Devices.Select(AbLegacyDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(AbLegacyTagRow.FromDefinition).ToList();
}
}
_devicesJson = System.Text.Json.JsonSerializer.Serialize(_devices, _jsonOpts);
_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);
_loaded = true;
}
@@ -229,7 +260,11 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts);
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -300,7 +335,11 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_devices, _tags), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbLegacyDriverOptions? TryDeserialize(string json)
{
@@ -308,6 +347,91 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — AbLegacyDeviceOptions is an immutable record.
public sealed class AbLegacyDeviceRow
{
public string HostAddress { get; set; } = "";
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// across a load→save.
private AbLegacyDeviceOptions? _source;
public AbLegacyDeviceRow Clone() => (AbLegacyDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbLegacyDeviceRow FromDefinition(AbLegacyDeviceOptions d) => new()
{
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
_source = d,
};
public AbLegacyDeviceOptions ToDefinition()
{
var baseDef = _source ?? new AbLegacyDeviceOptions(HostAddress.Trim(), PlcFamily);
return baseDef with
{
HostAddress = HostAddress.Trim(),
PlcFamily = PlcFamily,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(AbLegacyDeviceRow row, IReadOnlyList<AbLegacyDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Mutable VM for the modal editor — AbLegacyTagDefinition is an immutable record.
public sealed class AbLegacyTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string Address { get; set; } = "";
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private AbLegacyTagDefinition? _source;
public AbLegacyTagRow Clone() => (AbLegacyTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbLegacyTagRow FromDefinition(AbLegacyTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, Address = d.Address,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public AbLegacyTagDefinition ToDefinition()
{
var baseDef = _source ?? new AbLegacyTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(AbLegacyTagRow row, IReadOnlyList<AbLegacyTagRow> 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;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
@@ -1,8 +1,6 @@
@* Dispatch page: reads DriverInstance.DriverType and renders the matching typed editor
via <DynamicComponent>. Falls back to the legacy DriverEdit for any type not yet in
the map. The route collides with DriverEdit.razor's identical directive — that's
intentional. Task 3.4 removes the route from DriverEdit.razor. Blazor route conflicts
are runtime, not build-time, so the build succeeds now. *@
@* Dispatch page: reads DriverInstance.DriverType and dispatches to the matching typed editor
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
no registered typed page. *@
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@@ -1,6 +1,5 @@
@* TODO(3.3): This route collides with DriverEdit.razor's @page "/clusters/{ClusterId}/drivers/new".
Task 3.3 removes the /drivers/new directive from DriverEdit.razor so this page takes over.
Blazor resolves route conflicts at runtime, not compile time, so the build succeeds now. *@
@* Driver type picker — presents a card grid of registered driver types and links to the
per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
@page "/clusters/{ClusterId}/drivers/new"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@@ -189,43 +189,65 @@ else
</div>
</section>
@* Devices — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.20s">
<div class="panel-head">Devices</div>
<div style="padding:1rem">
<div class="form-text mb-2">
Each device represents one CNC. Device list editor (with CNC series selector) coming in a follow-up phase.
Format: <code>[{"hostAddress":"192.168.0.10:8193","deviceName":"CNC1","series":"Thirty_i"}]</code>
@* Devices *@
<CollectionEditor TRow="FocasDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".20s"
NewRow="@(() => new FocasDeviceRow())" Clone="@(r => r.Clone())"
Validate="FocasDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>CNC series</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.Series</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="192.168.0.10:8193" /></div>
<div class="col-md-3"><label class="form-label">CNC series</label>
<select class="form-select form-select-sm" @bind="d.Series">
@foreach (var e in Enum.GetValues<FocasCncSeries>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
@if (_form.DevicesJson is not null)
{
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.DevicesJson</pre>
}
else
{
<p class="text-muted"><em>No devices configured.</em></p>
}
</div>
</section>
</EditTemplate>
</CollectionEditor>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.23s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<div class="form-text mb-2">
Tag list editor coming in a follow-up phase. Tags reference device host addresses and FOCAS address strings
(e.g. <code>X0.0</code>, <code>R100</code>, <code>PARAM:1815/0</code>, <code>MACRO:500</code>).
@* Tags *@
<CollectionEditor TRow="FocasTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".23s"
NewRow="@(() => new FocasTagRow())" Clone="@(r => r.Clone())"
Validate="FocasTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</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-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="192.168.0.10:8193" /></div>
<div class="col-md-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. X0.0, R100, PARAM:1815/0, MACRO:500" /></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<FocasDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><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>
@if (_form.TagsJson is not null)
{
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.TagsJson</pre>
}
else
{
<p class="text-muted"><em>No tags configured.</em></p>
}
</div>
</section>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -260,6 +282,10 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<FocasDeviceRow> _devices = [];
private List<FocasTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -290,6 +316,8 @@ else
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(FocasTagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -300,7 +328,9 @@ else
_busy = true; _error = null;
try
{
var opts = _form.ToOptions();
var opts = _form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -371,7 +401,11 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static FocasDriverOptions? TryDeserialize(string json)
{
@@ -379,6 +413,93 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — FocasDeviceOptions is an immutable record.
public sealed class FocasDeviceRow
{
public string HostAddress { get; set; } = "";
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
// expose across a load→save.
private FocasDeviceOptions? _source;
public FocasDeviceRow Clone() => (FocasDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static FocasDeviceRow FromDefinition(FocasDeviceOptions d) => new()
{
HostAddress = d.HostAddress, Series = d.Series, DeviceName = d.DeviceName,
_source = d,
};
public FocasDeviceOptions ToDefinition()
{
var baseDef = _source ?? new FocasDeviceOptions(HostAddress.Trim());
return baseDef with
{
HostAddress = HostAddress.Trim(),
Series = Series,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(FocasDeviceRow row, IReadOnlyList<FocasDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Mutable VM for the modal editor — FocasTagDefinition is an immutable record.
public sealed class FocasTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string Address { get; set; } = "";
public FocasDataType DataType { get; set; } = FocasDataType.Int32;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private FocasTagDefinition? _source;
public FocasTagRow Clone() => (FocasTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static FocasTagRow FromDefinition(FocasTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, Address = d.Address,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public FocasTagDefinition ToDefinition()
{
var baseDef = _source ?? new FocasTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(FocasTagRow row, IReadOnlyList<FocasTagRow> 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;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Connection
@@ -404,52 +525,30 @@ else
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
// Collections JSON view (read-only)
public string? DevicesJson { get; set; }
public string? TagsJson { get; set; }
// Preserved originals (round-tripped unchanged)
private IReadOnlyList<FocasDeviceOptions> _devices = [];
private IReadOnlyList<FocasTagDefinition> _tags = [];
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
private static readonly System.Text.Json.JsonSerializerOptions _displayOpts = new()
public static FormModel FromOptions(FocasDriverOptions o) => new()
{
WriteIndented = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
HandleRecycleEnabled = o.HandleRecycle.Enabled,
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
FixedTreeEnabled = o.FixedTree.Enabled,
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
};
public static FormModel FromOptions(FocasDriverOptions o)
{
var m = new FormModel
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
HandleRecycleEnabled = o.HandleRecycle.Enabled,
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
FixedTreeEnabled = o.FixedTree.Enabled,
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
_devices = o.Devices,
_tags = o.Tags,
};
m.DevicesJson = o.Devices.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Devices, _displayOpts);
m.TagsJson = o.Tags.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Tags, _displayOpts);
return m;
}
public FocasDriverOptions ToOptions() => new()
public FocasDriverOptions ToOptions(
IReadOnlyList<FocasDeviceOptions> devices,
IReadOnlyList<FocasTagDefinition> tags) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new FocasProbeOptions
@@ -476,8 +575,8 @@ else
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
},
Devices = _devices,
Tags = _tags,
Devices = devices,
Tags = tags,
};
}
}
@@ -273,16 +273,44 @@ else
</div>
</section>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<p class="form-text mb-2">
Tag list — full list-editor coming in a follow-up phase. Edit tags via the Tag editor pages or by exporting/importing the driver config JSON.
</p>
<pre class="form-control form-control-sm mono" style="min-height:4rem;overflow:auto;white-space:pre-wrap;">@_tagsJson</pre>
</div>
</section>
<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>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -318,9 +346,8 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — rendered as read-only JSON.
private IReadOnlyList<ModbusTagDefinition> _tags = [];
private string _tagsJson = "[]";
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
private List<ModbusTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
@@ -360,10 +387,9 @@ else
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_tags = opts.Tags;
_tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList();
}
}
_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);
_loaded = true;
}
@@ -372,7 +398,7 @@ else
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts);
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -443,7 +469,7 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
private static ModbusDriverOptions? TryDeserialize(string json)
{
@@ -451,6 +477,60 @@ else
catch { return null; }
}
// 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.HoldingRegisters;
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; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (StringByteOrder, ArrayCount, Deadband, UnitId, CoalesceProhibited) across a load→save.
private ModbusTagDefinition? _source;
public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
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,
_source = d,
};
public ModbusTagDefinition ToDefinition()
{
var baseDef = _source ?? new ModbusTagDefinition(Name.Trim(), Region, 0, DataType);
return baseDef with
{
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;
}
}
// Flat mutable model — all scalars exposed as settable properties so Blazor @bind-Value works.
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
public sealed class FormModel
@@ -131,11 +131,25 @@ else
<div class="form-text">Default 10.</div>
</div>
</div>
@* Endpoint URLs list — read-only JSON view (full list-editor is a follow-up) *@
<div class="row g-3 mt-1">
<div class="col-12">
<label class="form-label">Endpoint URLs (failover list — read-only; edit via raw JSON import or use Endpoint URL above)</label>
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_endpointUrlsJson</pre>
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints"
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
Validate="EndpointUrlRow.ValidateRow">
<HeaderTemplate>
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="e">
<td class="mono">@e.Url</td>
</RowTemplate>
<EditTemplate Context="e">
<label class="form-label">Endpoint URL</label>
<input class="form-control form-control-sm mono" @bind="e.Url"
placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">When this list is non-empty, the single Endpoint URL above is ignored.</div>
</EditTemplate>
</CollectionEditor>
</div>
</div>
</div>
@@ -281,8 +295,10 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Read-only JSON snippets for collections that have no list editor yet.
private string _endpointUrlsJson = "[]";
// Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal.
private List<EndpointUrlRow> _endpoints = [];
// Read-only JSON snippet for the UnsMappingTable, which has no list editor yet.
private string _unsMappingTableJson = "{}";
protected override async Task OnInitializedAsync()
@@ -322,7 +338,7 @@ else
var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions();
_form = new FormModel();
_form.OpcUa = OpcUaClientFormModel.FromRecord(opts);
_endpointUrlsJson = System.Text.Json.JsonSerializer.Serialize(opts.EndpointUrls, _jsonOpts);
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
_unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
@@ -336,7 +352,7 @@ else
_busy = true; _error = null;
try
{
var opts = _form.OpcUa.ToRecord();
var opts = _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -407,7 +423,8 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.OpcUa.ToRecord(), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts);
private static OpcUaClientDriverOptions? TryDeserialize(string json)
{
@@ -422,11 +439,36 @@ else
public byte[] RowVersion { get; set; } = [];
}
/// <summary>
/// Mutable VM for a single endpoint URL row. EndpointUrls is a plain
/// <c>List&lt;string&gt;</c> (a failover list) so the row is a thin wrapper the
/// <see cref="CollectionEditor{TRow}"/> modal can bind to.
/// </summary>
public sealed class EndpointUrlRow
{
public string Url { get; set; } = "";
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
public string ToUrl() => Url.Trim();
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
return "Endpoint URL must start with opc.tcp://";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
return $"Duplicate endpoint '{row.Url}'.";
return null;
}
}
/// <summary>
/// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> with int wrappers for
/// TimeSpan fields so Blazor InputNumber can bind them.
/// EndpointUrls and UnsMappingTable are shown as read-only JSON; they survive round-trip
/// via the original deserialized record and are re-serialized unchanged.
/// EndpointUrls is edited via the CollectionEditor (held on the page as a row list and
/// threaded into <see cref="ToRecord"/>); UnsMappingTable is shown as read-only JSON and
/// survives round-trip via the original deserialized record, re-serialized unchanged.
/// </summary>
public sealed class OpcUaClientFormModel
{
@@ -461,8 +503,7 @@ else
// Diagnostics
public int ProbeTimeoutSeconds { get; set; } = 15;
// Preserved read-only collections (round-tripped unchanged from original record)
internal IReadOnlyList<string> _endpointUrls = [];
// Preserved read-only collection (round-tripped unchanged from original record)
internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
@@ -488,14 +529,13 @@ else
UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
_endpointUrls = r.EndpointUrls,
_unsMappingTable = r.UnsMappingTable,
};
public OpcUaClientDriverOptions ToRecord() => new()
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
{
EndpointUrl = EndpointUrl,
EndpointUrls = _endpointUrls,
EndpointUrls = endpointUrls,
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
ApplicationUri = ApplicationUri,
SessionName = SessionName,
@@ -145,23 +145,38 @@ else
</div>
</section>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.11s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<div class="form-text mb-2">
Tag list editor coming in a follow-up phase. To add/remove tags, edit the JSON directly in the raw driver config via the generic editor, or deploy via the import tooling.
@* Tags *@
<CollectionEditor TRow="S7TagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".11s"
NewRow="@(() => new S7TagRow())" Clone="@(r => r.Clone())"
Validate="S7TagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</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-6"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" @bind="t.Address"
placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" /></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<S7DataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">String length</label>
<input type="number" class="form-control form-control-sm" @bind="t.StringLength" />
<div class="form-text">Only for String type. Max 254.</div></div>
<div class="col-md-3"><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>
@if (_form.TagsJson is not null)
{
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.TagsJson</pre>
}
else
{
<p class="text-muted"><em>No tags configured.</em></p>
}
</div>
</section>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -196,6 +211,9 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Tags is a collection — edited via the CollectionEditor modal.
private List<S7TagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -226,6 +244,7 @@ else
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_tags = opts.Tags.Select(S7TagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -236,7 +255,7 @@ else
_busy = true; _error = null;
try
{
var opts = _form.ToOptions();
var opts = _form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -307,7 +326,8 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()), _jsonOpts);
private static S7DriverOptions? TryDeserialize(string json)
{
@@ -315,6 +335,53 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — S7TagDefinition is an immutable record.
public sealed class S7TagRow
{
public string Name { get; set; } = "";
public string Address { get; set; } = "";
public S7DataType DataType { get; set; } = S7DataType.Int16;
public bool Writable { get; set; } = true;
public int StringLength { get; set; } = 254;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private S7TagDefinition? _source;
public S7TagRow Clone() => (S7TagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static S7TagRow FromDefinition(S7TagDefinition d) => new()
{
Name = d.Name, Address = d.Address, DataType = d.DataType,
Writable = d.Writable, StringLength = d.StringLength,
_source = d,
};
public S7TagDefinition ToDefinition()
{
var baseDef = _source ?? new S7TagDefinition(Name.Trim(), Address.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
Address = Address.Trim(),
DataType = DataType,
Writable = Writable,
StringLength = StringLength,
};
}
public static string? ValidateRow(S7TagRow row, IReadOnlyList<S7TagRow> 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;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collection (Tags) is kept on the component (_tags) and passed in on ToOptions().
public sealed class FormModel
{
// Connection
@@ -331,43 +398,25 @@ else
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Tags JSON view (read-only)
public string? TagsJson { get; set; }
// Preserved originals (round-tripped unchanged from original options)
private IReadOnlyList<S7TagDefinition> _tags = [];
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(S7DriverOptions o)
public static FormModel FromOptions(S7DriverOptions o) => new()
{
string? tagsJson = o.Tags.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Tags,
new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
WriteIndented = true,
});
return new FormModel
{
Host = o.Host,
Port = o.Port,
CpuType = o.CpuType,
Rack = o.Rack,
Slot = o.Slot,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
TagsJson = tagsJson,
_tags = o.Tags,
};
}
Host = o.Host,
Port = o.Port,
CpuType = o.CpuType,
Rack = o.Rack,
Slot = o.Slot,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public S7DriverOptions ToOptions() => new()
public S7DriverOptions ToOptions(IReadOnlyList<S7TagDefinition> tags) => new()
{
Host = Host,
Port = Port,
@@ -382,7 +431,7 @@ else
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Tags = _tags,
Tags = tags,
};
}
}
@@ -132,42 +132,61 @@ else
</div>
</section>
@* Devices — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.11s">
<div class="panel-head">Devices</div>
<div style="padding:1rem">
<div class="form-text mb-2">
Each device is identified by AMS Net Id + port. Device list editor coming in a follow-up phase.
Format: <code>[{"hostAddress":"192.168.0.1.1.1:851","deviceName":"PLC1"}]</code>
@* Devices *@
<CollectionEditor TRow="TwinCATDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".11s"
NewRow="@(() => new TwinCATDeviceRow())" Clone="@(r => r.Clone())"
Validate="TwinCATDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address (AMS Net Id:port)</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="192.168.0.1.1.1:851" /></div>
<div class="col-md-6"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
@if (_form.DevicesJson is not null)
{
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.DevicesJson</pre>
}
else
{
<p class="text-muted"><em>No devices configured.</em></p>
}
</div>
</section>
</EditTemplate>
</CollectionEditor>
@* Tags — read-only JSON view *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Tags</div>
<div style="padding:1rem">
<div class="form-text mb-2">
Tag list editor coming in a follow-up phase. Tags reference device host addresses and TwinCAT symbol paths.
@* Tags *@
<CollectionEditor TRow="TwinCATTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
AnimationDelay=".14s"
NewRow="@(() => new TwinCATTagRow())" Clone="@(r => r.Clone())"
Validate="TwinCATTagRow.ValidateRow">
<HeaderTemplate>
<tr><th>Name</th><th>Device</th><th>Symbol path</th><th>Type</th><th>Writable</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="t">
<td class="mono">@t.Name</td><td class="mono">@t.DeviceHostAddress</td>
<td class="mono">@t.SymbolPath</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-6"><label class="form-label">Device host address</label>
<input class="form-control form-control-sm mono" @bind="t.DeviceHostAddress"
placeholder="192.168.0.1.1.1:851" /></div>
<div class="col-md-6"><label class="form-label">Symbol path</label>
<input class="form-control form-control-sm mono" @bind="t.SymbolPath"
placeholder="e.g. MAIN.bStart, GVL.Counter" /></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<TwinCATDataType>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><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>
@if (_form.TagsJson is not null)
{
<pre class="form-control form-control-sm mono" style="min-height:4rem;max-height:12rem;overflow:auto;white-space:pre-wrap">@_form.TagsJson</pre>
}
else
{
<p class="text-muted"><em>No tags configured.</em></p>
}
</div>
</section>
</EditTemplate>
</CollectionEditor>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
@@ -202,6 +221,10 @@ else
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<TwinCATDeviceRow> _devices = [];
private List<TwinCATTagRow> _tags = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
@@ -232,6 +255,8 @@ else
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList();
_tags = opts.Tags.Select(TwinCATTagRow.FromDefinition).ToList();
}
}
_loaded = true;
@@ -242,8 +267,11 @@ else
_busy = true; _error = null;
try
{
var opts = _form.ToOptions();
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
@@ -313,7 +341,11 @@ else
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList(),
_tags.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static TwinCATDriverOptions? TryDeserialize(string json)
{
@@ -321,6 +353,91 @@ else
catch { return null; }
}
// Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record.
public sealed class TwinCATDeviceRow
{
public string HostAddress { get; set; } = "";
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
// expose across a load→save.
private TwinCATDeviceOptions? _source;
public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new()
{
HostAddress = d.HostAddress, DeviceName = d.DeviceName,
_source = d,
};
public TwinCATDeviceOptions ToDefinition()
{
var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim());
return baseDef with
{
HostAddress = HostAddress.Trim(),
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(TwinCATDeviceRow row, IReadOnlyList<TwinCATDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Mutable VM for the modal editor — TwinCATTagDefinition is an immutable record.
public sealed class TwinCATTagRow
{
public string Name { get; set; } = "";
public string DeviceHostAddress { get; set; } = "";
public string SymbolPath { get; set; } = "";
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
public bool Writable { get; set; } = true;
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (WriteIdempotent) across a load→save.
private TwinCATTagDefinition? _source;
public TwinCATTagRow Clone() => (TwinCATTagRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static TwinCATTagRow FromDefinition(TwinCATTagDefinition d) => new()
{
Name = d.Name, DeviceHostAddress = d.DeviceHostAddress, SymbolPath = d.SymbolPath,
DataType = d.DataType, Writable = d.Writable,
_source = d,
};
public TwinCATTagDefinition ToDefinition()
{
var baseDef = _source ?? new TwinCATTagDefinition(Name.Trim(), DeviceHostAddress.Trim(), SymbolPath.Trim(), DataType);
return baseDef with
{
Name = Name.Trim(),
DeviceHostAddress = DeviceHostAddress.Trim(),
SymbolPath = SymbolPath.Trim(),
DataType = DataType,
Writable = Writable,
};
}
public static string? ValidateRow(TwinCATTagRow row, IReadOnlyList<TwinCATTagRow> 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;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Options
@@ -335,47 +452,25 @@ else
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 10;
// Collections JSON view (read-only)
public string? DevicesJson { get; set; }
public string? TagsJson { get; set; }
// Preserved originals (round-tripped unchanged)
private IReadOnlyList<TwinCATDeviceOptions> _devices = [];
private IReadOnlyList<TwinCATTagDefinition> _tags = [];
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
private static readonly System.Text.Json.JsonSerializerOptions _displayOpts = new()
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
{
WriteIndented = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
UseNativeNotifications = o.UseNativeNotifications,
EnableControllerBrowse = o.EnableControllerBrowse,
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public static FormModel FromOptions(TwinCATDriverOptions o)
{
var m = new FormModel
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
UseNativeNotifications = o.UseNativeNotifications,
EnableControllerBrowse = o.EnableControllerBrowse,
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
_devices = o.Devices,
_tags = o.Tags,
};
m.DevicesJson = o.Devices.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Devices, _displayOpts);
m.TagsJson = o.Tags.Count == 0 ? null
: System.Text.Json.JsonSerializer.Serialize(o.Tags, _displayOpts);
return m;
}
public TwinCATDriverOptions ToOptions() => new()
public TwinCATDriverOptions ToOptions(
IReadOnlyList<TwinCATDeviceOptions> devices,
IReadOnlyList<TwinCATTagDefinition> tags) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
UseNativeNotifications = UseNativeNotifications,
@@ -388,8 +483,8 @@ else
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Devices = _devices,
Tags = _tags,
Devices = devices,
Tags = tags,
};
}
}
@@ -3,5 +3,4 @@
<PageTitle>OtOpcUa</PageTitle>
<h1>OtOpcUa Admin</h1>
<p>v2 fused host. Use the nav above to manage deployments.</p>
<p class="text-muted">Most v1 admin pages were removed by the live-edit migration — see follow-up F15 for the per-page restoration plan.</p>
<p>Use the nav above to configure clusters, drivers, and tags, then deploy.</p>
@@ -1,33 +1,21 @@
@page "/role-grants"
@* Per Q4 of the AdminUI rebuild plan, v2 replaced v1's per-cluster RoleGrants table with a
fleet-wide LDAP-group → role map. This page surfaces the mapping read-only; the source of
truth is Authentication:Ldap:GroupToRole in appsettings (editable on the host filesystem, not
from the UI yet). *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@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
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Role grants</h4>
</div>
<section class="panel notice rise" style="animation-delay:.02s">
LDAP group membership determines fleet roles. Edit the mapping in
<span class="mono">appsettings.json</span> under <span class="mono">Authentication:Ldap:GroupToRole</span>
and restart the admin node (or sign out + back in for cached claims to refresh). UI-driven
editing of the mapping is deferred — it implies a config-reload mechanism that doesn't exist
yet.
</section>
@if (_options is null)
@if (_options is not null)
{
<p>Loading…</p>
}
else
{
<section class="card-grid rise mt-3" style="animation-delay:.08s">
<section class="card-grid rise" style="animation-delay:.02s">
<div class="metric-card">
<div class="panel-head">LDAP binding</div>
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
@@ -40,16 +28,61 @@ else
}
</div>
</section>
}
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Group → role (database)</div>
<div style="padding:1rem">
<div class="d-flex gap-2 align-items-center flex-wrap">
<input class="form-control form-control-sm mono" style="max-width:32rem"
@bind="_newGroup" placeholder="cn=fleet-admin,ou=groups,..." />
<select class="form-select form-select-sm" style="max-width:14rem" @bind="_newRole">
@foreach (var role in Enum.GetValues<AdminRole>())
{
<option value="@role">@role</option>
}
</select>
<button class="btn btn-sm btn-primary" @onclick="AddAsync" disabled="@_busy">Add</button>
</div>
@if (_error is not null)
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>LDAP group</th><th>Role</th><th></th></tr></thead>
<tbody>
@if (_rows.Count == 0)
{
<tr><td colspan="3" class="text-muted">No database role grants. Authentication falls back to the appsettings map below.</td></tr>
}
else
{
@foreach (var r in _rows)
{
<tr>
<td><span class="mono">@r.LdapGroup</span></td>
<td><span class="chip chip-idle">@r.Role</span></td>
<td><button class="btn btn-sm btn-link text-danger" @onclick="() => DeleteAsync(r.Id)" disabled="@_busy">Delete</button></td>
</tr>
}
}
</tbody>
</table>
</div>
</section>
@if (_options is not null)
{
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Group → role mapping (@(_options.GroupToRole?.Count ?? 0))</div>
<div class="panel-head">Fallback (appsettings) (@(_options.GroupToRole?.Count ?? 0))</div>
<div style="padding:1rem 1rem 0" class="text-muted small">
These <span class="mono">Authentication:Ldap:GroupToRole</span> entries apply when a group has no database row above.
</div>
@if (_options.GroupToRole is null || _options.GroupToRole.Count == 0)
{
<div style="padding:1rem" class="text-muted">
No mapping configured. Every authenticated user lands with zero roles —
the fallback authorization policy will refuse every request. Add a
<span class="mono">GroupToRole</span> entry before deploying.
</div>
<div style="padding:1rem" class="text-muted">No appsettings fallback mapping configured.</div>
}
else
{
@@ -73,9 +106,47 @@ else
@code {
private LdapOptions? _options;
private IReadOnlyList<LdapGroupRoleMapping> _rows = [];
private string _newGroup = "";
private AdminRole _newRole = AdminRole.ConfigViewer;
private string? _error;
private bool _busy;
protected override void OnInitialized()
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; }
_busy = true;
StateHasChanged();
try
{
await RoleMappings.CreateAsync(new LdapGroupRoleMapping
{
LdapGroup = _newGroup.Trim(), Role = _newRole, IsSystemWide = true, ClusterId = null,
}, default);
_newGroup = "";
_newRole = AdminRole.ConfigViewer;
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync(Guid id)
{
_error = null; _busy = true;
StateHasChanged();
try { await RoleMappings.DeleteAsync(id, default); await ReloadAsync(); }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
}
@@ -0,0 +1,129 @@
@* 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 @key="Items[idx]">
@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();
}
}
@@ -1,7 +1,5 @@
@* Identity section shared across the generic DriverEdit page and the typed driver pages (Phase 4).
The parent page owns the <EditForm> and all data loading/persistence — this component is
purely a section of inputs.
Set ShowDriverType=true on the generic editor; typed pages leave it false (type is fixed). *@
@* Identity section shared by the typed driver pages. The parent page owns the <EditForm> and all
data loading/persistence — this component is purely a section of inputs. *@
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@@ -1,16 +1,42 @@
@* Resilience overrides — JSON textarea. Typed-form-ifying Polly is a follow-up; for now this
matches the legacy DriverEdit.razor behaviour exactly. *@
@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">
<InputTextArea Value="@ResilienceConfig"
ValueExpression="() => ResilienceConfig"
ValueChanged="OnChangedAsync"
rows="6"
class="form-control form-control-sm mono"
placeholder="Leave blank to use tier defaults" />
<div class="form-text">Polly pipeline overrides per docs/v2/driver-stability.md — bulkhead, retry counts, breaker thresholds. Null = use the driver type's tier defaults.</div>
<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>
@@ -18,9 +44,24 @@
[Parameter] public string? ResilienceConfig { get; set; }
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private async Task OnChangedAsync(string? newValue)
private ResilienceFormModel _m = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
ResilienceConfig = newValue;
await ResilienceConfigChanged.InvokeAsync(newValue);
// 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);
}
}
@@ -0,0 +1,104 @@
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(), StringComparer.OrdinalIgnoreCase);
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 (next task) 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; }
}
}
@@ -15,11 +15,6 @@ public static class EndpointRouteBuilderExtensions
/// <summary>
/// Mounts the AdminUI Razor components and the AdminUI static asset pipeline at the root.
/// Call from the fused Host's Program.cs alongside <c>app.MapOtOpcUaAuth()</c>.
///
/// Razor component migration from legacy <c>OtOpcUa.Admin/Components/</c> is staged for
/// follow-up F15 — 47 .razor files plus codebehind. Until then this extension wires the
/// Blazor pipeline but the only built-in components are the v2-native ones added in this
/// library (e.g. <c>Deployments</c>, Task 52).
/// </summary>
/// <typeparam name="TApp">The root component type for Razor pages.</typeparam>
/// <param name="app">The endpoint route builder.</param>
@@ -7,9 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// Browser-facing fleet-status push channel. Subscribers receive <see cref="FleetStatusChanged"/>
/// snapshots whenever the admin-role <c>FleetStatusBroadcaster</c> publishes a diff.
///
/// Server-side bridge from <c>FleetStatusBroadcaster.broadcast</c> → <c>IHubContext&lt;FleetStatusHub&gt;</c>
/// is staged for follow-up F16. For now the hub is a passive channel; SignalR clients connect
/// and stay idle until the bridge lands.
/// Server pushes fleet-status updates to connected clients via <c>FleetStatusSignalRBridge</c>
/// (DistributedPubSub 'fleet-status' → <c>IHubContext&lt;FleetStatusHub&gt;</c>).
/// </summary>
public sealed class FleetStatusHub : Hub
{
@@ -6,6 +6,9 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
@@ -40,6 +43,7 @@ public static class AuthEndpoints
private static async Task<IResult> LoginAsync(
HttpContext http,
ILdapAuthService ldap,
ILdapGroupRoleMappingService roleMappings,
CancellationToken ct)
{
var isForm = http.Request.HasFormContentType;
@@ -83,13 +87,27 @@ public static class AuthEndpoints
return Results.Redirect("/login" + qs);
}
IReadOnlyList<string> roles = result.Roles;
try
{
var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
roles = RoleMapper.Merge(result.Roles, dbRows);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// A DB hiccup must never block sign-in — fall back to the appsettings baseline roles.
http.RequestServices.GetService<ILoggerFactory>()?
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
.LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline roles", username);
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, result.Username ?? username),
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
};
foreach (var role in result.Roles)
foreach (var role in roles)
claims.Add(new Claim(ClaimTypes.Role, role));
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
/// <summary>
@@ -24,4 +26,21 @@ public static class RoleMapper
}
return [.. roles];
}
/// <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];
}
}
@@ -89,6 +89,9 @@ public static class ServiceCollectionExtensions
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
o.AddPolicy("DriverOperator", policy =>
policy.RequireRole("DriverOperator", "FleetAdmin"));
// FleetAdmin: full administrative access; gates fleet-wide pages such as RoleGrants.
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin"));
});
return services;
@@ -146,4 +146,23 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
// no exception
}
/// <summary>Verifies that a system-wide row (IsSystemWide=true, ClusterId=null) appears in both ListAllAsync and GetByGroupsAsync.</summary>
[Fact]
public async Task SystemWide_Row_AppearsIn_ListAll_And_GetByGroups()
{
var svc = new LdapGroupRoleMappingService(_db);
var saved = await svc.CreateAsync(
Make("cn=sysadmins,dc=x", AdminRole.FleetAdmin, clusterId: null, isSystemWide: true),
CancellationToken.None);
saved.IsSystemWide.ShouldBeTrue();
saved.ClusterId.ShouldBeNull();
var all = await svc.ListAllAsync(CancellationToken.None);
all.ShouldContain(r => r.Id == saved.Id);
var byGroup = await svc.GetByGroupsAsync(["cn=sysadmins,dc=x"], CancellationToken.None);
byGroup.ShouldContain(r => r.Id == saved.Id);
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -14,6 +15,12 @@ public sealed class AbCipDriverPageFormSerializationTests
WriteIndented = false,
};
private static readonly JsonSerializerOptions TestJsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -78,4 +85,110 @@ public sealed class AbCipDriverPageFormSerializationTests
back.ShouldNotBeNull();
back.ProbeTimeoutSeconds.ShouldBe(10);
}
[Fact]
public void DeviceRow_round_trips_through_definition()
{
var row = new AbCipDriverPage.AbCipDeviceRow
{
HostAddress = "ab://10.0.0.1/1,0", PlcFamily = AbCipPlcFamily.CompactLogix, DeviceName = "PLC-A",
};
var def = row.ToDefinition();
var back = AbCipDriverPage.AbCipDeviceRow.FromDefinition(def);
back.HostAddress.ShouldBe("ab://10.0.0.1/1,0");
back.PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix);
back.DeviceName.ShouldBe("PLC-A");
}
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
var original = new AbCipDeviceOptions(
"ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-A",
AllowPacking: true, ConnectionSize: 4002);
var row = AbCipDriverPage.AbCipDeviceRow.FromDefinition(original);
row.HostAddress = "ab://10.0.0.2/1,0";
var back = row.ToDefinition();
back.HostAddress.ShouldBe("ab://10.0.0.2/1,0");
back.AllowPacking.ShouldBe(true);
back.ConnectionSize.ShouldBe(4002);
}
[Fact]
public void TagRow_round_trips_through_definition()
{
var row = new AbCipDriverPage.AbCipTagRow
{
Name = "Speed", DeviceHostAddress = "ab://10.0.0.1/1,0", TagPath = "Motor1.Speed",
DataType = AbCipDataType.Real, Writable = true,
};
var def = row.ToDefinition();
var back = AbCipDriverPage.AbCipTagRow.FromDefinition(def);
back.Name.ShouldBe("Speed");
back.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0");
back.TagPath.ShouldBe("Motor1.Speed");
back.DataType.ShouldBe(AbCipDataType.Real);
back.Writable.ShouldBeTrue();
}
[Fact]
public void TagRow_preserves_unedited_fields()
{
var original = new AbCipTagDefinition(
"Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Structure,
Writable: true, WriteIdempotent: true,
Members: [new AbCipStructureMember("Sub", AbCipDataType.DInt)],
SafetyTag: true);
var row = AbCipDriverPage.AbCipTagRow.FromDefinition(original);
row.Name = "Renamed";
var back = row.ToDefinition();
back.Name.ShouldBe("Renamed");
back.WriteIdempotent.ShouldBeTrue();
back.SafetyTag.ShouldBeTrue();
back.Members.ShouldNotBeNull();
back.Members!.Count.ShouldBe(1);
back.Members[0].Name.ShouldBe("Sub");
}
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
var rows = new List<AbCipDriverPage.AbCipDeviceRow> { new() { HostAddress = "ab://10.0.0.1/1,0" } };
AbCipDriverPage.AbCipDeviceRow.ValidateRow(new() { HostAddress = "ab://10.0.0.1/1,0" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void ValidateTagRow_rejects_duplicate_name()
{
var rows = new List<AbCipDriverPage.AbCipTagRow> { new() { Name = "Speed" } };
AbCipDriverPage.AbCipTagRow.ValidateRow(new() { Name = "Speed" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void Device_and_tag_lists_survive_options_serialize_round_trip()
{
var devices = new List<AbCipDeviceOptions>
{
new("ab://10.0.0.1/1,0", AbCipPlcFamily.ControlLogix, "PLC-1"),
new("ab://10.0.0.2/1,0", AbCipPlcFamily.CompactLogix, "PLC-2"),
};
var tags = new List<AbCipTagDefinition>
{
new("Speed", "ab://10.0.0.1/1,0", "Motor1.Speed", AbCipDataType.Real),
new("Run", "ab://10.0.0.2/1,0", "Motor2.Run", AbCipDataType.Bool),
};
var opts = new AbCipDriverPage.FormModel().ToOptions(devices, tags);
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
var back = JsonSerializer.Deserialize<AbCipDriverOptions>(json, TestJsonOpts)!;
back.Devices.Count.ShouldBe(2);
back.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0");
back.Tags.Count.ShouldBe(2);
back.Tags[0].Name.ShouldBe("Speed");
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
@@ -15,6 +16,12 @@ public sealed class AbLegacyDriverPageFormSerializationTests
WriteIndented = false,
};
private static readonly JsonSerializerOptions TestJsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -78,4 +85,102 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.ShouldNotBeNull();
back.ProbeTimeoutSeconds.ShouldBe(10);
}
[Fact]
public void DeviceRow_round_trips_through_definition()
{
var row = new AbLegacyDriverPage.AbLegacyDeviceRow
{
HostAddress = "10.0.0.10", PlcFamily = AbLegacyPlcFamily.MicroLogix, DeviceName = "PLC-A",
};
var def = row.ToDefinition();
var back = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(def);
back.HostAddress.ShouldBe("10.0.0.10");
back.PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix);
back.DeviceName.ShouldBe("PLC-A");
}
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
var original = new AbLegacyDeviceOptions("10.0.0.10", AbLegacyPlcFamily.Plc5, "PLC-A");
var row = AbLegacyDriverPage.AbLegacyDeviceRow.FromDefinition(original);
row.HostAddress = "10.0.0.20";
var back = row.ToDefinition();
back.HostAddress.ShouldBe("10.0.0.20");
back.PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
back.DeviceName.ShouldBe("PLC-A");
}
[Fact]
public void TagRow_round_trips_through_definition()
{
var row = new AbLegacyDriverPage.AbLegacyTagRow
{
Name = "Level", DeviceHostAddress = "10.0.0.10", Address = "N7:5",
DataType = AbLegacyDataType.Int, Writable = true,
};
var def = row.ToDefinition();
var back = AbLegacyDriverPage.AbLegacyTagRow.FromDefinition(def);
back.Name.ShouldBe("Level");
back.DeviceHostAddress.ShouldBe("10.0.0.10");
back.Address.ShouldBe("N7:5");
back.DataType.ShouldBe(AbLegacyDataType.Int);
back.Writable.ShouldBeTrue();
}
[Fact]
public void TagRow_preserves_unedited_fields()
{
var original = new AbLegacyTagDefinition(
"Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int,
Writable: true, WriteIdempotent: true);
var row = AbLegacyDriverPage.AbLegacyTagRow.FromDefinition(original);
row.Name = "Renamed";
var back = row.ToDefinition();
back.Name.ShouldBe("Renamed");
back.WriteIdempotent.ShouldBeTrue();
}
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
var rows = new List<AbLegacyDriverPage.AbLegacyDeviceRow> { new() { HostAddress = "10.0.0.10" } };
AbLegacyDriverPage.AbLegacyDeviceRow.ValidateRow(new() { HostAddress = "10.0.0.10" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void ValidateTagRow_rejects_duplicate_name()
{
var rows = new List<AbLegacyDriverPage.AbLegacyTagRow> { new() { Name = "Level" } };
AbLegacyDriverPage.AbLegacyTagRow.ValidateRow(new() { Name = "Level" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void Device_and_tag_lists_survive_options_serialize_round_trip()
{
var devices = new List<AbLegacyDeviceOptions>
{
new("10.0.0.10", AbLegacyPlcFamily.Slc500, "PLC-1"),
new("10.0.0.11", AbLegacyPlcFamily.MicroLogix, "PLC-2"),
};
var tags = new List<AbLegacyTagDefinition>
{
new("Level", "10.0.0.10", "N7:5", AbLegacyDataType.Int),
new("Pump", "10.0.0.11", "B3:0/0", AbLegacyDataType.Bit),
};
var opts = new AbLegacyDriverPage.FormModel().ToOptions(devices, tags);
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
var back = JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, TestJsonOpts)!;
back.Devices.Count.ShouldBe(2);
back.Devices[0].HostAddress.ShouldBe("10.0.0.10");
back.Tags.Count.ShouldBe(2);
back.Tags[0].Name.ShouldBe("Level");
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -14,6 +15,12 @@ public sealed class FocasDriverPageFormSerializationTests
WriteIndented = false,
};
private static readonly JsonSerializerOptions TestJsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -116,7 +123,7 @@ public sealed class FocasDriverPageFormSerializationTests
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.FocasDriverPage.FormModel.FromOptions(opts);
var roundTripped = form.ToOptions();
var roundTripped = form.ToOptions([], []);
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
roundTripped.Probe.Enabled.ShouldBeTrue();
@@ -132,4 +139,104 @@ public sealed class FocasDriverPageFormSerializationTests
roundTripped.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(5));
roundTripped.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(45));
}
[Fact]
public void DeviceRow_round_trips_through_definition()
{
var row = new FocasDriverPage.FocasDeviceRow
{
HostAddress = "192.168.0.10:8193", Series = FocasCncSeries.Thirty_i, DeviceName = "CNC1",
};
var def = row.ToDefinition();
var back = FocasDriverPage.FocasDeviceRow.FromDefinition(def);
back.HostAddress.ShouldBe("192.168.0.10:8193");
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
back.DeviceName.ShouldBe("CNC1");
}
[Fact]
public void DeviceRow_preserves_unedited_fields()
{
var original = new FocasDeviceOptions("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i);
var row = FocasDriverPage.FocasDeviceRow.FromDefinition(original);
row.HostAddress = "192.168.0.20:8193";
var back = row.ToDefinition();
back.HostAddress.ShouldBe("192.168.0.20:8193");
back.DeviceName.ShouldBe("CNC1");
back.Series.ShouldBe(FocasCncSeries.Thirty_i);
}
[Fact]
public void TagRow_round_trips_through_definition()
{
var row = new FocasDriverPage.FocasTagRow
{
Name = "MacroVar", DeviceHostAddress = "192.168.0.10:8193", Address = "MACRO:500",
DataType = FocasDataType.Float64, Writable = true,
};
var def = row.ToDefinition();
var back = FocasDriverPage.FocasTagRow.FromDefinition(def);
back.Name.ShouldBe("MacroVar");
back.DeviceHostAddress.ShouldBe("192.168.0.10:8193");
back.Address.ShouldBe("MACRO:500");
back.DataType.ShouldBe(FocasDataType.Float64);
back.Writable.ShouldBeTrue();
}
[Fact]
public void TagRow_preserves_unedited_fields()
{
var original = new FocasTagDefinition(
"MacroVar", "192.168.0.10:8193", "MACRO:500", FocasDataType.Float64,
Writable: true, WriteIdempotent: true);
var row = FocasDriverPage.FocasTagRow.FromDefinition(original);
row.Name = "Renamed";
var back = row.ToDefinition();
back.Name.ShouldBe("Renamed");
back.WriteIdempotent.ShouldBeTrue();
}
[Fact]
public void ValidateDeviceRow_rejects_duplicate_host()
{
var rows = new List<FocasDriverPage.FocasDeviceRow> { new() { HostAddress = "192.168.0.10:8193" } };
FocasDriverPage.FocasDeviceRow.ValidateRow(new() { HostAddress = "192.168.0.10:8193" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void ValidateTagRow_rejects_duplicate_name()
{
var rows = new List<FocasDriverPage.FocasTagRow> { new() { Name = "MacroVar" } };
FocasDriverPage.FocasTagRow.ValidateRow(new() { Name = "MacroVar" }, rows, null)
.ShouldNotBeNull();
}
[Fact]
public void Device_and_tag_lists_survive_options_serialize_round_trip()
{
var devices = new List<FocasDeviceOptions>
{
new("192.168.0.10:8193", "CNC1", FocasCncSeries.Thirty_i),
new("192.168.0.20:8193", "CNC2", FocasCncSeries.Zero_i_F),
};
var tags = new List<FocasTagDefinition>
{
new("MacroVar", "192.168.0.10:8193", "MACRO:500", FocasDataType.Float64),
new("Flag", "192.168.0.20:8193", "X0.0", FocasDataType.Bit),
};
var opts = new FocasDriverPage.FormModel().ToOptions(devices, tags);
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
var back = JsonSerializer.Deserialize<FocasDriverOptions>(json, TestJsonOpts)!;
back.Devices.Count.ShouldBe(2);
back.Devices[0].HostAddress.ShouldBe("192.168.0.10:8193");
back.Devices[0].Series.ShouldBe(FocasCncSeries.Thirty_i);
back.Tags.Count.ShouldBe(2);
back.Tags[0].Name.ShouldBe("MacroVar");
back.Tags[0].Address.ShouldBe("MACRO:500");
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -14,6 +15,12 @@ public sealed class ModbusDriverPageFormSerializationTests
WriteIndented = false,
};
private static readonly JsonSerializerOptions TestJsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
[Fact]
public void RoundTrip_PreservesKnownFields()
{
@@ -104,4 +111,63 @@ public sealed class ModbusDriverPageFormSerializationTests
back.ShouldNotBeNull();
back.ProbeTimeoutSeconds.ShouldBe(10);
}
[Fact]
public void TagRow_round_trips_through_definition()
{
var row = new ModbusDriverPage.ModbusTagRow
{
Name = "Pump1_Speed", Region = ModbusRegion.HoldingRegisters, 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.HoldingRegisters, 1, ModbusDataType.Int16),
new("B", ModbusRegion.Coils, 2, ModbusDataType.Bool),
};
var opts = new ModbusDriverPage.FormModel().ToOptions(tags);
var json = JsonSerializer.Serialize(opts, TestJsonOpts);
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();
}
[Fact]
public void ToDefinition_preserves_unedited_fields()
{
var original = new ModbusTagDefinition(
"T", ModbusRegion.HoldingRegisters, 5, ModbusDataType.Int16,
StringByteOrder: ModbusStringByteOrder.LowByteFirst,
ArrayCount: 10, Deadband: 0.5, UnitId: 3, CoalesceProhibited: true);
var row = ModbusDriverPage.ModbusTagRow.FromDefinition(original);
row.Name = "Renamed";
var back = row.ToDefinition();
back.Name.ShouldBe("Renamed");
back.UnitId.ShouldBe((byte)3);
back.ArrayCount.ShouldBe(10);
back.Deadband.ShouldBe(0.5);
back.StringByteOrder.ShouldBe(ModbusStringByteOrder.LowByteFirst);
back.CoalesceProhibited.ShouldBeTrue();
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
@@ -85,9 +86,10 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
[Fact]
public void FormModel_RoundTrip_PreservesAllFields()
{
// Construct options with non-default values for every editable property plus
// non-empty EndpointUrls and UnsMappingTable — both are "read-only" in the form
// but must survive the FormModel translation unchanged.
// Construct options with non-default values for every editable property plus a
// non-empty UnsMappingTable (read-only in the form, round-tripped via the original
// record). EndpointUrls is now edited via the CollectionEditor on the page and is
// threaded into ToRecord explicitly; see EndpointUrls_ListRoundTrip_PreservesOrder.
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://backup:4840" };
var unsMappingTable = new Dictionary<string, string>
{
@@ -123,7 +125,7 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
};
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(original);
var result = form.ToRecord();
var result = form.ToRecord(endpointUrls);
result.EndpointUrl.ShouldBe("opc.tcp://fallback:4840");
result.EndpointUrls.Count.ShouldBe(2);
@@ -153,4 +155,92 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
result.UnsMappingTable["Line2/"].ShouldBe("Site/Area1/Line2");
result.ProbeTimeoutSeconds.ShouldBe(25);
}
[Fact]
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
{
var row = OpcUaClientDriverPage.EndpointUrlRow.FromUrl(" opc.tcp://plc:4840 ");
row.Url.ShouldBe(" opc.tcp://plc:4840 ");
row.ToUrl().ShouldBe("opc.tcp://plc:4840");
}
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsBlank()
{
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>();
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " " };
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
error.ShouldBe("URL is required.");
}
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsNonOpcTcpScheme()
{
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>();
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "http://plc:4840" };
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
error.ShouldBe("Endpoint URL must start with opc.tcp://");
}
[Fact]
public void EndpointUrlRow_ValidateRow_RejectsDuplicate()
{
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>
{
new() { Url = "opc.tcp://primary:4840" },
new() { Url = "opc.tcp://backup:4840" },
};
// Adding a new row (editIndex null) duplicating the first — case-insensitive, whitespace-insensitive.
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = " OPC.TCP://primary:4840 " };
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, null);
error.ShouldNotBeNull();
error.ShouldContain("Duplicate endpoint");
}
[Fact]
public void EndpointUrlRow_ValidateRow_AllowsEditingRowInPlace()
{
var all = new List<OpcUaClientDriverPage.EndpointUrlRow>
{
new() { Url = "opc.tcp://primary:4840" },
new() { Url = "opc.tcp://backup:4840" },
};
// Editing index 0 and keeping the same URL must not flag itself as a duplicate.
var row = new OpcUaClientDriverPage.EndpointUrlRow { Url = "opc.tcp://primary:4840" };
var error = OpcUaClientDriverPage.EndpointUrlRow.ValidateRow(row, all, 0);
error.ShouldBeNull();
}
[Fact]
public void EndpointUrls_ListRoundTrip_PreservesOrder()
{
// The page holds endpoints as a List<EndpointUrlRow>; loading from EndpointUrls and
// converting back must preserve order (the failover list is ordered, primary first).
var endpointUrls = new List<string> { "opc.tcp://primary:4840", "opc.tcp://secondary:4840", "opc.tcp://tertiary:4840" };
var rows = endpointUrls
.Select(OpcUaClientDriverPage.EndpointUrlRow.FromUrl)
.ToList();
var roundTripped = rows.Select(r => r.ToUrl()).ToList();
roundTripped.Count.ShouldBe(3);
roundTripped[0].ShouldBe("opc.tcp://primary:4840");
roundTripped[1].ShouldBe("opc.tcp://secondary:4840");
roundTripped[2].ShouldBe("opc.tcp://tertiary:4840");
var form = OpcUaClientDriverPage.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions());
var result = form.ToRecord(roundTripped);
result.EndpointUrls.Count.ShouldBe(3);
result.EndpointUrls[0].ShouldBe("opc.tcp://primary:4840");
result.EndpointUrls[2].ShouldBe("opc.tcp://tertiary:4840");
}
}
@@ -0,0 +1,49 @@
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 Malformed_json_yields_empty_model()
{
var m = ResilienceFormModel.FromJson("{ not valid json");
m.BulkheadMaxConcurrent.ShouldBeNull();
m.Policies["Read"].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);
opts.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0);
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
@@ -95,7 +96,10 @@ public sealed class S7DriverPageFormSerializationTests
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.S7DriverPage.FormModel.FromOptions(opts);
var roundTripped = form.ToOptions();
var tagRows = opts.Tags
.Select(ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers.S7DriverPage.S7TagRow.FromDefinition)
.ToList();
var roundTripped = form.ToOptions(tagRows.Select(r => r.ToDefinition()).ToList());
roundTripped.Host.ShouldBe("192.168.1.50");
roundTripped.Port.ShouldBe(102);
@@ -117,4 +121,94 @@ public sealed class S7DriverPageFormSerializationTests
roundTripped.Tags[1].Name.ShouldBe("Status");
roundTripped.Tags[1].Writable.ShouldBeFalse();
}
[Fact]
public void S7TagRow_RoundTrip_PreservesEditableFields()
{
var def = new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true, StringLength: 80);
var row = S7DriverPage.S7TagRow.FromDefinition(def);
var back = row.ToDefinition();
back.Name.ShouldBe("Speed");
back.Address.ShouldBe("DB1.DBD0");
back.DataType.ShouldBe(S7DataType.Float32);
back.Writable.ShouldBeTrue();
back.StringLength.ShouldBe(80);
}
[Fact]
public void S7TagRow_CarriesThroughUneditedFields()
{
// WriteIdempotent is not exposed by the editor; it must survive FromDefinition→edit→ToDefinition.
var def = new S7TagDefinition("Setpoint", "DB10.DBD0", S7DataType.Float32, Writable: true, WriteIdempotent: true);
var row = S7DriverPage.S7TagRow.FromDefinition(def);
row.Name = "SetpointRenamed";
row.Writable = false;
var back = row.ToDefinition();
back.Name.ShouldBe("SetpointRenamed");
back.Writable.ShouldBeFalse();
// Un-edited field carried through via _source.
back.WriteIdempotent.ShouldBeTrue();
}
[Fact]
public void S7TagRow_ValidateRow_RejectsDuplicateNames()
{
var all = new List<S7DriverPage.S7TagRow>
{
S7DriverPage.S7TagRow.FromDefinition(new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32)),
S7DriverPage.S7TagRow.FromDefinition(new S7TagDefinition("Status", "DB1.DBW4", S7DataType.Int16)),
};
// Editing index 1 to a name that case-insensitively collides with index 0.
var edited = all[1].Clone();
edited.Name = "speed";
S7DriverPage.S7TagRow.ValidateRow(edited, all, editIndex: 1)
.ShouldBe("Duplicate tag name 'speed'.");
// Required-name guard.
var blank = new S7DriverPage.S7TagRow();
S7DriverPage.S7TagRow.ValidateRow(blank, all, editIndex: null)
.ShouldBe("Name is required.");
// Unique name passes.
var ok = all[1].Clone();
ok.Name = "Torque";
S7DriverPage.S7TagRow.ValidateRow(ok, all, editIndex: 1).ShouldBeNull();
}
[Fact]
public void TagList_SerializeRoundTrip_PreservesTags()
{
var opts = new S7DriverOptions
{
Host = "10.1.1.1",
Tags =
[
new S7TagDefinition("Speed", "DB1.DBD0", S7DataType.Float32, Writable: true),
new S7TagDefinition("Name", "DB2.DBB0", S7DataType.String, Writable: false, StringLength: 32),
],
};
var optsSkip = new JsonSerializerOptions(_opts)
{
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
};
var json = JsonSerializer.Serialize(opts, optsSkip);
var back = JsonSerializer.Deserialize<S7DriverOptions>(json, optsSkip);
back.ShouldNotBeNull();
back.Tags.Count.ShouldBe(2);
back.Tags[0].Name.ShouldBe("Speed");
back.Tags[0].Address.ShouldBe("DB1.DBD0");
back.Tags[0].DataType.ShouldBe(S7DataType.Float32);
back.Tags[0].Writable.ShouldBeTrue();
back.Tags[1].Name.ShouldBe("Name");
back.Tags[1].DataType.ShouldBe(S7DataType.String);
back.Tags[1].StringLength.ShouldBe(32);
back.Tags[1].Writable.ShouldBeFalse();
}
}
@@ -84,7 +84,7 @@ public sealed class TwinCATDriverPageFormSerializationTests
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.FormModel.FromOptions(opts);
var roundTripped = form.ToOptions();
var roundTripped = form.ToOptions([], []);
roundTripped.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
roundTripped.UseNativeNotifications.ShouldBeTrue();
@@ -95,4 +95,127 @@ public sealed class TwinCATDriverPageFormSerializationTests
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(2));
roundTripped.ProbeTimeoutSeconds.ShouldBe(15);
}
[Fact]
public void DeviceRow_RoundTrip_PreservesEditableFields()
{
var def = new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1");
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
var back = row.ToDefinition();
back.HostAddress.ShouldBe("192.168.0.1.1.1:851");
back.DeviceName.ShouldBe("PLC1");
}
[Fact]
public void DeviceRow_CarriesThroughUneditedSourceFields()
{
// Edit only DeviceName; HostAddress on the source must survive the round-trip via _source.
var def = new TwinCATDeviceOptions("10.0.0.5.1.1:851", "Original");
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(def);
row.DeviceName = "Renamed";
var back = row.ToDefinition();
back.HostAddress.ShouldBe("10.0.0.5.1.1:851");
back.DeviceName.ShouldBe("Renamed");
}
[Fact]
public void DeviceRow_ValidateRow_RejectsDuplicateHostAddress()
{
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow.FromDefinition(new TwinCATDeviceOptions("192.168.0.1.1.1:851"));
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow { HostAddress = "192.168.0.1.1.1:851" };
var all = new[] { existing, dup };
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATDeviceRow.ValidateRow(dup, all, editIndex: 1);
error.ShouldNotBeNull();
error.ShouldContain("Duplicate");
}
[Fact]
public void TagRow_RoundTrip_PreservesEditableFields()
{
var def = new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real, Writable: false);
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(def);
var back = row.ToDefinition();
back.Name.ShouldBe("Speed");
back.DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851");
back.SymbolPath.ShouldBe("MAIN.rSpeed");
back.DataType.ShouldBe(TwinCATDataType.Real);
back.Writable.ShouldBeFalse();
}
[Fact]
public void TagRow_CarriesThroughUneditedWriteIdempotent()
{
// WriteIdempotent is not exposed by the editor; it must survive a load→edit→save via _source.
var def = new TwinCATTagDefinition("Cmd", "192.168.0.1.1.1:851", "GVL.Start", TwinCATDataType.Bool,
Writable: true, WriteIdempotent: true);
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(def);
row.Name = "CmdRenamed"; // touch an edited field
var back = row.ToDefinition();
back.Name.ShouldBe("CmdRenamed");
back.WriteIdempotent.ShouldBeTrue();
}
[Fact]
public void TagRow_ValidateRow_RejectsDuplicateName()
{
var existing = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATTagRow.FromDefinition(
new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real));
var dup = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATTagRow { Name = "SPEED" }; // case-insensitive collision
var all = new[] { existing, dup };
var error = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.TwinCATTagRow.ValidateRow(dup, all, editIndex: 1);
error.ShouldNotBeNull();
error.ShouldContain("Duplicate");
}
[Fact]
public void FormModel_ToOptions_SerializesDeviceAndTagLists()
{
var form = ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers
.TwinCATDriverPage.FormModel.FromOptions(new TwinCATDriverOptions());
var devices = new[] { new TwinCATDeviceOptions("192.168.0.1.1.1:851", "PLC1") };
var tags = new[]
{
new TwinCATTagDefinition("Speed", "192.168.0.1.1.1:851", "MAIN.rSpeed", TwinCATDataType.Real,
Writable: true, WriteIdempotent: true),
};
var opts = form.ToOptions(devices, tags);
var json = JsonSerializer.Serialize(opts, _opts);
var back = JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _opts);
back.ShouldNotBeNull();
back.Devices.Count.ShouldBe(1);
back.Devices[0].HostAddress.ShouldBe("192.168.0.1.1.1:851");
back.Devices[0].DeviceName.ShouldBe("PLC1");
back.Tags.Count.ShouldBe(1);
back.Tags[0].Name.ShouldBe("Speed");
back.Tags[0].DeviceHostAddress.ShouldBe("192.168.0.1.1.1:851");
back.Tags[0].SymbolPath.ShouldBe("MAIN.rSpeed");
back.Tags[0].DataType.ShouldBe(TwinCATDataType.Real);
back.Tags[0].Writable.ShouldBeTrue();
back.Tags[0].WriteIdempotent.ShouldBeTrue();
}
}
@@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -12,6 +12,9 @@ using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
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.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
@@ -29,6 +32,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
{
private IHost _host = null!;
private TestServer _server = null!;
private readonly StubLdapGroupRoleMappingService _roleMappings = new();
private static CancellationToken Ct => TestContext.Current.CancellationToken;
@@ -58,6 +62,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
}).Build();
services.AddOtOpcUaAuth(configuration);
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
// The login handler now resolves the DB role-map service via DI to merge
// DB-backed grants on top of the appsettings baseline. Register the stub so
// the minimal-API handler can be constructed; tests drive its rows.
services.AddSingleton<ILdapGroupRoleMappingService>(_roleMappings);
});
web.Configure(app =>
{
@@ -174,6 +182,79 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
token!.Split('.').Length.ShouldBe(3);
}
/// <summary>A system-wide DB row for a group the user holds grants an extra role on top of
/// the appsettings baseline; the merged role surfaces in the issued JWT's Role claims.</summary>
[Fact]
public async Task Login_merges_db_role_grant_into_claims()
{
// StubLdapAuthService returns Groups ["ReadOnly"], baseline Roles ["ConfigViewer"].
// A system-wide row maps "ReadOnly" → FleetAdmin, so the merged set is both.
_roleMappings.Rows.Add(new LdapGroupRoleMapping
{
Id = Guid.NewGuid(),
LdapGroup = "ReadOnly",
Role = AdminRole.FleetAdmin,
IsSystemWide = true,
ClusterId = null,
});
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(tokenReq, loginResponse);
var tokenResp = await client.SendAsync(tokenReq, Ct);
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved
roles.ShouldContain("FleetAdmin"); // DB grant merged in
}
/// <summary>When the DB role-map lookup throws, sign-in still succeeds with the appsettings
/// baseline roles — a DB hiccup must never block login.</summary>
[Fact]
public async Task Login_when_db_role_map_throws_falls_back_to_baseline_roles()
{
_roleMappings.Throws = true;
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
// Login proceeds despite the simulated DB outage.
loginResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(tokenReq, loginResponse);
var tokenResp = await client.SendAsync(tokenReq, Ct);
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
roles.ShouldContain("ConfigViewer"); // baseline still present
}
/// <summary>Extracts the "Role" claim values from a JWT's payload segment.</summary>
private static IReadOnlyList<string> JwtRoleClaims(string jwt)
{
var payloadSegment = jwt.Split('.')[1];
var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
var json = JsonDocument.Parse(Convert.FromBase64String(padded));
if (!json.RootElement.TryGetProperty("Role", out var roleProp)) return [];
return roleProp.ValueKind == JsonValueKind.Array
? [.. roleProp.EnumerateArray().Select(e => e.GetString()!)]
: [roleProp.GetString()!];
}
/// <summary>Tests that logout clears the cookie.</summary>
[Fact]
public async Task Logout_clears_the_cookie()
@@ -260,4 +341,38 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
Error: "Invalid username or password"));
}
}
/// <summary>
/// In-memory stub for the DB-backed group→role mapping service. Tests seed <see cref="Rows"/>
/// and the login handler merges any system-wide row whose group the user holds. Set
/// <see cref="Throws"/> to simulate a DB outage and exercise the baseline-roles fallback.
/// </summary>
private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService
{
public List<LdapGroupRoleMapping> Rows { get; } = [];
public bool Throws { get; set; }
/// <summary>Returns seeded rows whose group matches one of <paramref name="ldapGroups"/>.</summary>
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
{
if (Throws) throw new InvalidOperationException("simulated DB outage");
var groups = new HashSet<string>(ldapGroups, StringComparer.OrdinalIgnoreCase);
IReadOnlyList<LdapGroupRoleMapping> matched =
[.. Rows.Where(r => groups.Contains(r.LdapGroup))];
return Task.FromResult(matched);
}
/// <summary>Not exercised by these tests.</summary>
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <summary>Not exercised by these tests.</summary>
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <summary>Not exercised by these tests.</summary>
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
}
@@ -1,5 +1,7 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
@@ -59,4 +61,22 @@ public sealed class RoleMapperTests
roles.ShouldBe(new[] { "FleetAdmin" });
}
[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"]);
}