scripts/compliance/phase-6-4-compliance.ps1 turns stub TODOs into 11 real checks covering: - Stream A data layer: UnsImpactAnalyzer + DraftRevisionToken + cross-cluster rejection (decision #82) + all three move kinds (LineMove / AreaRename / LineMerge). - Stream B data layer: EquipmentCsvImporter + version marker '# OtOpcUaCsv v1' + decision-#117 required columns + decision-#139 optional columns including DeviceManualUri + duplicate-ZTag rejection + unknown-column rejection. Four [DEFERRED] surfaces tracked explicitly with task IDs: - Stream A UI drag/drop (task #153) - Stream B staging + finalize + UI (task #155) - Stream C DiffViewer refactor (task #156) - Stream D OPC 40010 Identification sub-folder + Razor component (task #157) Cross-cutting: full solution dotnet test passes 1159 >= 1137 pre-Phase-6.4 baseline; pre-existing Client.CLI Subscribe flake tolerated. docs/v2/implementation/phase-6-4-admin-ui-completion.md status updated from DRAFT to SHIPPED (data layer). Four Blazor / SignalR / EF / address-space follow-ups tracked as tasks — the visual-compliance review pattern from Phase 6.1 Stream E applies to each. `Phase 6.4 compliance: PASS` — exit 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
Phase 6.4 — Admin UI Completion
Status: SHIPPED (data layer) 2026-04-19 — Stream A.2 (UnsImpactAnalyzer + DraftRevisionToken) and Stream B.1 (EquipmentCsvImporter parser) merged to
v2in PR #91. Exit gate in PR #92.Deferred follow-ups (Blazor UI + staging tables + address-space wiring):
- Stream A UI — UnsTab MudBlazor drag/drop + 409 concurrent-edit modal + Playwright smoke (task #153).
- Stream B follow-up — EquipmentImportBatch staging + FinaliseImportBatch transaction + CSV import UI (task #155).
- Stream C — DiffViewer refactor into base + 6 section plugins + 1000-row cap + SignalR paging (task #156).
- Stream D — IdentificationFields.razor + DriverNodeManager OPC 40010 sub-folder exposure (task #157).
Baseline pre-Phase-6.4: 1137 solution tests → post-Phase-6.4 data layer: 1159 passing (+22).
Branch:
v2/phase-6-4-admin-ui-completionEstimated 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:
- 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. - Equipment CSV import + 5-identifier search — decision #95 + #117. Current state: basic form; no CSV parser; search indexes only ZTag.
- Draft-generation diff viewer — enhance existing
DiffViewer.razorto show generation-diff not just staged-edit diff; highlight ACL grant changes (lands after Phase 6.2). _baseequipment-class Identification fields exposure — decision #138–139. Columns exist onEquipment; 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 itemsadmin-ui.mdre-skimmed for screen layouts- Existing
EquipmentTab.razor/UnsTab.razor/DraftEditor.razordiff'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)
- 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. - A.2
UnsImpactAnalyzerservice. Inputs:(DraftGenerationId, MoveOperation, DraftRevisionToken). Outputs:ImpactPreview { AffectedEquipmentCount, AffectedTagCount, CascadeWarnings[], DraftRevisionToken }. Pure-function shape; testable in isolation. - 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 a409 Conflict / refresh-requiredmodal rather than silently overwriting. - 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.
- 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)
- 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. - B.2
EquipmentImportBatch+EquipmentImportRowstaging tables (migration). Import writes preview rows to staging via chunked inserts; staging never blocksEquipmentorExternalIdReservation. Preview query reads staging + validates each row against the currentEquipmentstate +ExternalIdReservationfreshness. - B.3
ImportPreviewUI — 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". - B.4
FinaliseImportBatch— atomic finalize. One EF transaction applies accepted rows toEquipment+ExternalIdReservation; duration bounded regardless of input size (the atomic step is a bulk-insert, not per-row row-by-row). Rollback = drop batch row viaDropImportBatch;Equipmentnever partially mutates. - 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-breakpublished > draftthenRowVersion DESC. Typeahead shows which field matched via trailing badge. - 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
ExternalIdReservationinsert test asserts retryable-conflict handling.
Stream C — Diff viewer enhancements (4 days)
- C.1 Refactor
DiffViewer.razorinto a base component + section plugins. Plugins:StructuralDiffSection(UNS tree),EquipmentDiffSection,TagDiffSection,AclDiffSection(Phase 6.2),RedundancyDiffSection(Phase 6.3),IdentificationDiffSection. - 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.
- C.3 Subtree-rename diffs (decision #115 bulk restructure) surface as summary only by default regardless of row count.
- 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)
- D.1
IdentificationFields.razorcomponent. 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. - D.2
DriverNodeManagerequipment-folder builder — after building the equipment node, inspect the 9 Identification columns; if any non-null, add anIdentificationsub-folder with variable-per-non-null-field. ACL binding: sub-folder + variables inherit the sameScopeIdas the Equipment node (Phase 6.2's trie treats them as part of the Equipment scope — no new scope level). - D.3 Address-space smoke test via Client.CLI: browse an equipment node, assert
Identificationsub-folder present when columns are set, absent when all null, variables match the field values. - D.4 ACL integration test: a user with Equipment-level grant reads the
Identificationvariables without needing a separate grant; a user without the Equipment grant getsBadUserAccessDeniedon 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-requiredinstead 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 v1first 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:
FinaliseImportBatchtransaction bounded < 30 s for a 10k-row import; pre-finalize stagings visible only to the importing user; rollback viaDropImportBatch. - Concurrent import + external reservation: concurrent test — third party inserts to
ExternalIdReservationmid-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
Identificationsub-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
BadUserAccessDeniedon both. - Visual parity reviewer: named role (
FleetAdminuser, not the implementation lead) compares side-by-side againstadmin-ui.md§Visual-Design reference panels; signoff artefact is a checked-in screenshot set underdocs/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:
DiffViewerrefactor + 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 testpasses;phase-6-4-compliance.ps1exits 0; exit-gate doc
Adversarial Review — 2026-04-19 (Codex, via codex-rescue subagent)
- Crit · ACCEPT — Stale UNS impact preview can overwrite concurrent draft edits. Change: each preview carries a
DraftRevisionToken;Confirmcompares against the current draft + rejects with a409 Conflict / refresh-requiredmodal if any draft edit landed since the preview was generated. Stream A.3 updated. - 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; finalFinaliseImportBatchis atomic overEquipment+ExternalIdReservation. Rollback is "drop the batch row" — the real Equipment table is never partially mutated. - 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 v1so future shape changes are unambiguous. - 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. - 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). - 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.
- 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 / MonthOfConstructiondropped from Phase 6.4 — they belong to a future OPC 40010 widening decision. - High · ACCEPT —
Identificationsubtree 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/Xas inheriting theEquipmentscope's grants. Documented in Phase 6.2'sacl-design.mdcross-reference update. - Low · ACCEPT — Visual-review gate names nonexistent reviewer role. Change: rubric defined — a named "Admin UX reviewer" (role
FleetAdminuser, not the implementation lead) compares side-by-side screenshots against theadmin-ui.md§Visual-Design reference panels; signoff artefact is a checked-in screenshot set underdocs/v2/visual-compliance/phase-6-4/. - 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.