# 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.
