Files
lmxopcua/docs/v2/implementation/phase-6-4-admin-ui-completion.md
Joseph Doherty ba31f200f6 Phase 6 reconcile — merge adjustments into plan bodies, add decisions #143-162, scaffold compliance stubs
After shipping the four Phase 6 plan drafts (PRs 77-80), the adversarial-review
adjustments lived only as trailing "Review" sections. An implementer reading
Stream A would find the original unadjusted guidance, then have to cross-reference
the review to reconcile. This PR makes the plans genuinely executable:

1. Merges every ACCEPTed review finding into the actual Scope / Stream / Compliance
   sections of each phase plan:
   - phase-6-1: Scope table rewrite (per-capability retry, (instance,host) pipeline key,
     MemoryTracking vs MemoryRecycle split, hybrid watchdog formula, demand-aware
     wedge detector, generation-sealed LiteDB). Streams A/B/D + Compliance rewritten.
   - phase-6-2: AuthorizationDecision tri-state, control/data-plane separation,
     MembershipFreshnessInterval (15 min), AuthCacheMaxStaleness (5 min),
     subscription stamp-and-reevaluate. Stream C widened to 11 OPC UA operations.
   - phase-6-3: 8-state ServiceLevel matrix (OPC UA Part 5 §6.3.34-compliant),
     two-layer peer probe (/healthz + UaHealthProbe), apply-lease via await using,
     publish-generation fencing, InvalidTopology runtime state, ServerUriArray
     self-first + peers. New Stream F (interop matrix + Galaxy failover).
   - phase-6-4: DraftRevisionToken concurrency control, staged-import via
     EquipmentImportBatch with user-scoped visibility, CSV header version marker,
     decision-#117-aligned identifier columns, 1000-row diff cap,
     decision-#139 OPC 40010 fields, Identification inherits Equipment ACL.

2. Appends decisions #143 through #162 to docs/v2/plan.md capturing the
   architectural commitments the adjustments created. Each decision carries its
   dated rationale so future readers know why the choice was made.

3. Scaffolds scripts/compliance/phase-6-{1,2,3,4}-compliance.ps1 — PowerShell
   stubs with Assert-Todo / Assert-Pass / Assert-Fail helpers. Every check
   maps to a Stream task ID from the corresponding phase plan. Currently all
   checks are TODO and scripts exit 0; each implementation task is responsible
   for replacing its TODO with a real check before closing that task. Saved
   as UTF-8 with BOM so Windows PowerShell 5.1 parses em-dash characters
   without breaking.

Net result: the Phase 6.1 plan is genuinely ready to execute. Stream A.3 can
start tomorrow without reconciling Streams vs. Review on every task; the
compliance script is wired to the Stream IDs; plan.md has the architectural
commitments that justify the Stream choices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 03:49:41 -04:00

19 KiB
Raw Blame History

Phase 6.4 — Admin UI Completion

Status: DRAFT — Phase 1 Stream E shipped the Admin scaffold + core pages; several feature-completeness items from its completion checklist (phase-1-configuration-and-admin-scaffold.md §Stream E) never landed. This phase closes them.

Branch: v2/phase-6-4-admin-ui-completion Estimated duration: 2 weeks Predecessor: Phase 6.3 (Redundancy runtime) — reuses the /cluster/{id} page layout for the new tabs Successor: v2 release-readiness capstone (Task #121)

Phase Objective

Close the Admin UI feature-completeness checklist that Phase 1 Stream E exit gate left open. Each item below is an existing phase-1-configuration-and-admin-scaffold.md completion-checklist entry that is currently unchecked.

Gaps to close:

  1. UNS Structure tab drag/move with impact preview — decision #115 + admin-ui.md §"UNS". Current state: list-only render; no drag reorder; no "X lines / Y equipment impacted" preview.
  2. Equipment CSV import + 5-identifier search — decision #95 + #117. Current state: basic form; no CSV parser; search indexes only ZTag.
  3. Draft-generation diff viewer — enhance existing DiffViewer.razor to show generation-diff not just staged-edit diff; highlight ACL grant changes (lands after Phase 6.2).
  4. _base equipment-class Identification fields exposure — decision #138139. Columns exist on Equipment; no Admin UI field group; no address-space exposure of the OPC 40010 sub-folder.

Scope — What Changes

Concern Change
Admin/Pages/UnsTab.razor Tree component with drag-drop using MudBlazor.TreeView + MudBlazor.DropTarget (existing transitive dep — no new third-party package). Native HTML5 DnD rejected because virtualization + DnD on 500+ nodes doesn't combine reliably. Each drag fires a "Compute Impact" call carrying a DraftRevisionToken; modal preview ("Moving Line 'Oven-2' from 'Packaging' to 'Assembly' will re-home 14 equipment + re-parent 237 tags"). Confirm step re-checks the token and rejects with a 409 Conflict / refresh-required modal if the draft advanced between preview and commit.
Admin/Services/UnsImpactAnalyzer.cs New service. Given a move-operation (line move, area rename, line merge), computes cascade counts + DraftRevisionToken at preview time. Pure-function shape; testable in isolation.
Admin/Pages/EquipmentTab.razor Add CSV-import button → modal with file picker + dry-run preview. Identifier search uses the canonical decision #117 set: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid. Typeahead probes each column with a ranking query (exact match score 100 → prefix 50 → opt-in LIKE 20; published > draft tie-break). Result row shows which field matched via trailing badge.
Admin/Services/EquipmentCsvImporter.cs New service. CSV header row must start with # OtOpcUaCsv v1 (version marker — future shape changes bump the version). Columns: ZTag, MachineCode, SAPID, EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri. Parser rejects unknown columns + blank required fields + duplicate ZTags + missing UnsLines.
Staged-import table EquipmentImportBatch New entity { Id, CreatedAtUtc, CreatedBy, RowsStaged, RowsAccepted, RowsRejected, FinalisedAtUtc? } + child EquipmentImportRow records. Import writes rows in chunks to the staging table (not to Equipment). FinaliseImportBatch is the atomic finalize step that applies all accepted rows to Equipment + ExternalIdReservation in one transaction — short + bounded regardless of input size. Rollback = drop the batch row; Equipment never partially mutates.
Admin/Pages/DraftEditor.razor + DiffViewer.razor Diff viewer refactored into a base component + section plugins: StructuralDiffSection, EquipmentDiffSection, TagDiffSection, AclDiffSection (Phase 6.2), RedundancyDiffSection (Phase 6.3), IdentificationDiffSection. Each section has a 1000-row hard cap; over-cap renders an aggregate summary + "Load full diff" button streaming 500-row pages via SignalR. Subtree-rename diffs (decision #115 bulk restructure) surface as summary only by default.
Admin/Components/IdentificationFields.razor New component. Renders the OPC 40010 field set per decision #139: Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri. ProductInstanceUri / DeviceRevision / MonthOfConstruction dropped from this phase — they need a separate decision-log widening.
OtOpcUa.Server/OpcUa/DriverNodeManager — Equipment folder build When an Equipment row has non-null Identification fields, the server adds an Identification sub-folder under the Equipment node containing one variable per non-null field. ACL binding: the sub-folder + variables inherit the Equipment scope's grants from Phase 6.2's trie — no new scope level added. Documented in acl-design.md cross-reference update.

Scope — What Does NOT Change

Item Reason
Admin UI visual language Bootstrap 5 / cookie auth / sidebar layout unchanged — consistency with ScadaLink design reference.
LDAP auth flow Already shipped in Phase 1. Phase 6.4 is additive UI only.
Core abstractions / driver layer Admin UI changes don't touch drivers.
Equipment-class template schema validation Still deferred (decision #112 — schemas repo not landed). We expose the Identification fields but don't validate against a template hierarchy.
Drag/move to other clusters Out of scope — equipment is cluster-scoped per decision #82. Cross-cluster migration is a different workflow.

Entry Gate Checklist

  • Phase 6.2 merged (ACL grants are part of the new diff viewer sections)
  • Phase 6.3 merged (redundancy-role changes are part of the diff viewer)
  • phase-1-configuration-and-admin-scaffold.md §Stream E completion checklist re-read — confirm these are the remaining items
  • admin-ui.md re-skimmed for screen layouts
  • Existing EquipmentTab.razor / UnsTab.razor / DraftEditor.razor diff'd against what ships today so the edits are additive not destructive
  • Dev Galaxy available for OPC 40010 exposure smoke testing

Task Breakdown

Stream A — UNS drag/reorder + impact preview (5 days)

  1. A.1 1000-node synthetic seed fixture. Drag-latency bench against MudBlazor.TreeView + MudBlazor.DropTarget — commit to the component if latency budget (100 ms drag-enter feedback) holds; fall back to flat-list reorder UI (Area/Line dropdowns) with loss of visual drag affordance otherwise.
  2. A.2 UnsImpactAnalyzer service. Inputs: (DraftGenerationId, MoveOperation, DraftRevisionToken). Outputs: ImpactPreview { AffectedEquipmentCount, AffectedTagCount, CascadeWarnings[], DraftRevisionToken }. Pure-function shape; testable in isolation.
  3. A.3 Modal preview wired to UnsImpactAnalyzer. Confirm re-reads the current draft revision + compares against the preview's token; if the draft advanced (another operator saved a different edit), show a 409 Conflict / refresh-required modal rather than silently overwriting.
  4. A.4 Cross-cluster drop attempts: target disabled + toast "Equipment is cluster-scoped (decision #82). To move across clusters, use Export → Import on the Cluster detail page." Plus help link.
  5. A.5 Playwright (or equivalent) smoke test: drag a line across areas, assert modal shows right counts, assert draft row reflects the move; concurrent-edit test runs two sessions + asserts the later Confirm hits the 409.

Stream B — Equipment CSV import + 5-identifier search (5 days)

  1. B.1 EquipmentCsvImporter. Strict RFC 4180 parser (per decision #95). Header row validation: first line must match # OtOpcUaCsv v1 — future versions fork parser versions. Required columns: ZTag, MachineCode, SAPID, EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName. Optional: Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri. Parser rejects unknown columns + blank required fields + duplicate ZTags.
  2. B.2 EquipmentImportBatch + EquipmentImportRow staging tables (migration). Import writes preview rows to staging via chunked inserts; staging never blocks Equipment or ExternalIdReservation. Preview query reads staging + validates each row against the current Equipment state + ExternalIdReservation freshness.
  3. B.3 ImportPreview UI — per-row accept/reject table. Reject reasons: "ZTag already exists in draft", "ExternalIdReservation conflict with Cluster X", "UnsLineName not found in draft UNS tree", etc. Operator reviews + clicks "Commit".
  4. B.4 FinaliseImportBatch — atomic finalize. One EF transaction applies accepted rows to Equipment + ExternalIdReservation; duration bounded regardless of input size (the atomic step is a bulk-insert, not per-row row-by-row). Rollback = drop batch row via DropImportBatch; Equipment never partially mutates.
  5. B.5 Five-identifier search. Rank SQL: exact match any identifier = score 100, prefix match = 50, LIKE-fuzzy (opt-in via ?fuzzy=true) = 20; tie-break published > draft then RowVersion DESC. Typeahead shows which field matched via trailing badge.
  6. B.6 Smoke tests: 100-row CSV with 10 conflicts (5 ZTag dupes, 3 reservation clashes, 2 missing UnsLines); 10k-row perf test asserting finalize txn < 30 s; concurrent import + external ExternalIdReservation insert test asserts retryable-conflict handling.

Stream C — Diff viewer enhancements (4 days)

  1. C.1 Refactor DiffViewer.razor into a base component + section plugins. Plugins: StructuralDiffSection (UNS tree), EquipmentDiffSection, TagDiffSection, AclDiffSection (Phase 6.2), RedundancyDiffSection (Phase 6.3), IdentificationDiffSection.
  2. C.2 Each section renders collapsed by default; counts + top-line summary always visible. 1000-row hard cap per section — over-cap sections render aggregate summary (e.g. "237 equipment re-parented from Packaging to Assembly") with a "Load full diff" button that streams 500-row pages via SignalR.
  3. C.3 Subtree-rename diffs (decision #115 bulk restructure) surface as summary only by default regardless of row count.
  4. C.4 Tests: seed two generations with deliberate diffs; assert every section reports the right counts + top-line summary + hard-cap behavior.

Stream D — OPC 40010 Identification exposure (3 days)

  1. D.1 IdentificationFields.razor component. Renders the 9 decision #139 fields: Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri. Labelled inputs; nullable columns show empty input; required-field validation on commit only.
  2. D.2 DriverNodeManager equipment-folder builder — after building the equipment node, inspect the 9 Identification columns; if any non-null, add an Identification sub-folder with variable-per-non-null-field. ACL binding: sub-folder + variables inherit the same ScopeId as the Equipment node (Phase 6.2's trie treats them as part of the Equipment scope — no new scope level).
  3. D.3 Address-space smoke test via Client.CLI: browse an equipment node, assert Identification sub-folder present when columns are set, absent when all null, variables match the field values.
  4. D.4 ACL integration test: a user with Equipment-level grant reads the Identification variables without needing a separate grant; a user without the Equipment grant gets BadUserAccessDenied on both the Equipment node + its Identification variables.

Compliance Checks (run at exit gate)

  • UNS drag/move: drag a line across areas; modal preview shows correct impacted-equipment + impacted-tag counts.
  • Concurrent-edit safety: two-session test — session B saves a draft edit after session A opened the preview; session A's Confirm returns 409 Conflict / refresh-required instead of overwriting.
  • Cross-cluster drop: dropping equipment across cluster boundaries is disabled + shows actionable toast pointing to Export/Import workflow.
  • 1000-node tree: drag operations on a 1000-node seed maintain < 100 ms drag-enter feedback.
  • CSV header version: file missing # OtOpcUaCsv v1 first line is rejected pre-parse.
  • CSV canonical identifier set: columns match decision #117 (ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid); drift from the earlier draft surfaces as a test failure.
  • Staged-import atomicity: FinaliseImportBatch transaction bounded < 30 s for a 10k-row import; pre-finalize stagings visible only to the importing user; rollback via DropImportBatch.
  • Concurrent import + external reservation: concurrent test — third party inserts to ExternalIdReservation mid-finalize; finalize retries with conflict handling; no corruption.
  • 5-identifier search ranking: exact matches outrank prefix matches; published outranks draft for equal scores.
  • Diff viewer section caps: 2000-row subtree-rename diff renders as summary only; "Load full diff" streams in pages.
  • OPC 40010 field list match: rendered field group matches decision #139 exactly; no extra fields.
  • OPC 40010 exposure: Client.CLI browse shows Identification sub-folder when equipment has non-null columns; absent when all null.
  • ACL inheritance for Identification: integration test — Equipment-grant user reads Identification; no-grant user gets BadUserAccessDenied on both.
  • Visual parity reviewer: named role (FleetAdmin user, not the implementation lead) compares side-by-side against admin-ui.md §Visual-Design reference panels; signoff artefact is a checked-in screenshot set under docs/v2/visual-compliance/phase-6-4/.

Risks and Mitigations

Risk Likelihood Impact Mitigation
UNS drag-drop janky on large trees (>500 nodes) Medium Medium Virtualize the tree component; default-collapse nested areas; test with a synthetic 1000-equipment seed
CSV import performance on 10k-row imports Medium Medium Stream-parse rather than load-into-memory; preview renders in batches of 100; commit is chunked-EF-insert with progress bar
Diff viewer becomes unwieldy with many sections Low Medium Each section collapsed by default; top-line summary row always shown; Phase 6.4 caps at 6 sections
OPC 40010 sub-folder accidentally exposes NULL/empty identification columns as empty-string variables Low Low Column null-check in the builder; drop variables whose DB value is null
5-identifier search pulls full table Medium Medium Indexes on each of ZTag/SAPID/UniqueId/Alias1/Alias2; search query uses a UNION of 5 indexed lookups; falls back to LIKE only on explicit operator opt-in

Completion Checklist

  • Stream A: UnsImpactAnalyzer + drag-drop tree + modal preview + Playwright smoke
  • Stream B: EquipmentCsvImporter + preview modal + 5-identifier search + conflict-rollback test
  • Stream C: DiffViewer refactor + 6 section plugins + 2-generation diff test
  • Stream D: IdentificationFields.razor + address-space builder change + Client.CLI browse test
  • Visual-compliance reviewer signoff
  • Full solution dotnet test passes; phase-6-4-compliance.ps1 exits 0; exit-gate doc

Adversarial Review — 2026-04-19 (Codex, via codex-rescue subagent)

  1. Crit · ACCEPT — Stale UNS impact preview can overwrite concurrent draft edits. Change: each preview carries a DraftRevisionToken; Confirm compares against the current draft + rejects with a 409 Conflict / refresh-required modal if any draft edit landed since the preview was generated. Stream A.3 updated.
  2. High · ACCEPT — CSV import atomicity is internally contradictory (single EF transaction vs. chunked inserts). Change: one explicit model — staged-import table (EquipmentImportBatch { Id, CreatedAtUtc, RowsStaged, RowsAccepted, RowsRejected }) receives rows in chunks; final FinaliseImportBatch is atomic over Equipment + ExternalIdReservation. Rollback is "drop the batch row" — the real Equipment table is never partially mutated.
  3. Crit · ACCEPT — Identifier contract rewrite mis-cites decisions. Change: revert to the admin-ui.md + decision #117 canonical set — ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid. CSV header follows that set verbatim. Introduce a separate decision entry for versioned CSV header shape before adding any new column; CSV header row must start with # OtOpcUaCsv v1 so future shape changes are unambiguous.
  4. Med · ACCEPT — Search ordering undefined. Change: rank SQL — exact match on any identifier scores 100; prefix match 50; LIKE-fuzzy 20; published > draft tie-breaker; ORDER BY score DESC, RowVersion DESC. Typeahead shows which field matched via trailing badge.
  5. High · ACCEPT — HTML5 DnD on virtualized tree is aspirational. Change: Stream A.2 rewritten — commits to MudBlazor.TreeView + MudBlazor.DropTarget (already a transitive dep via the existing Admin UI). Build a 1000-node synthetic seed in A.1 + validate drag-latency budget before implementing impact preview. If MudBlazor can't hit the budget, fall back to a flat-list reorder UI with Area/Line dropdowns (loss of visual drag affordance but unblocks the feature).
  6. Med · ACCEPT — Collapsed-by-default doesn't handle generation-sized diffs. Change: each diff section has a hard row cap (1000 by default). Over-cap sections render an aggregate summary + "Load full diff" button that streams via SignalR in 500-row pages. Decision #115 subtree renames surface as a "N equipment re-parented under X → Y" summary instead of row-by-row.
  7. High · ACCEPT — OPC 40010 field list doesn't match decision #139. Change: field group realigned to Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri. ProductInstanceUri / DeviceRevision / MonthOfConstruction dropped from Phase 6.4 — they belong to a future OPC 40010 widening decision.
  8. High · ACCEPTIdentification subtree unreconciled with ACL hierarchy (Phase 6.2 6-level scope). Change: address-space builder creates the Identification sub-folder under the Equipment node with the same ScopeId as Equipment — no new scope level. ACL evaluator treats …/Equipment/Identification/X as inheriting the Equipment scope's grants. Documented in Phase 6.2's acl-design.md cross-reference update.
  9. Low · ACCEPT — Visual-review gate names nonexistent reviewer role. Change: rubric defined — a named "Admin UX reviewer" (role FleetAdmin user, not the implementation lead) compares side-by-side screenshots against the admin-ui.md §Visual-Design reference panels; signoff artefact is a checked-in screenshot set under docs/v2/visual-compliance/phase-6-4/.
  10. Med · ACCEPT — Cross-cluster drag/drop lacks loud failure path. Change: on drop across cluster boundary, disable the drop target + show a toast "Equipment is cluster-scoped (decision #82). To move across clusters, use the Export → Import workflow on the Cluster detail page." Plus a help link. Tested in Stream A.4.