dohertj2 dohertj2
  • Joined on 2026-02-20
dohertj2 merged pull request dohertj2/lmxopcua#138 2026-04-19 22:32:47 -04:00
UnsTab native HTML5 drag/drop + 409 conflict modal (#153 UI slice)
dohertj2 created pull request dohertj2/lmxopcua#138 2026-04-19 22:32:37 -04:00
UnsTab native HTML5 drag/drop + 409 conflict modal (#153 UI slice)
dohertj2 created branch uns-tab-drag-drop in dohertj2/lmxopcua 2026-04-19 22:32:34 -04:00
dohertj2 pushed to uns-tab-drag-drop at dohertj2/lmxopcua 2026-04-19 22:32:34 -04:00
5ee510dc1a UnsTab native HTML5 drag/drop + 409 concurrent-edit modal + optimistic-concurrency commit path. Closes UI slice of task #153 (Phase 6.4 Stream A UI follow-up). Playwright E2E smoke is split into new task #199 — Playwright install + WebApplicationFactory + seeded-DB harness is genuinely its own infra-setup PR. Native HTML5 attributes (draggable, @ondragstart, @ondragover, @ondragleave, @ondrop) deliberately over MudBlazor per the task title — no MudBlazor ever joins this project. Two new service methods on UnsService land the data layer the existing UnsImpactAnalyzer assumed but which didn't actually exist: (1) LoadSnapshotAsync(generationId) — walks UnsAreas + UnsLines + per-line equipment counts + builds a UnsTreeSnapshot including a 16-char SHA-256 revision token computed deterministically over the sorted (kind, id, parent, name, notes) tuple-set so it's stable across processes + changes whenever any row is added / modified / deleted; (2) MoveLineAsync(generationId, expectedToken, lineId, targetAreaId) — re-parents one line inside the same draft under an EF transaction, recomputes the current revision token from freshly-loaded rows, and throws DraftRevisionConflictException when the caller-supplied token no longer matches. Token mismatch means another operator mutated the draft between preview + commit + the move rolls back rather than clobbering their work. No-op same-area drop is a silent return. Cross-generation move is prevented by the generationId filter on the transaction reads. UnsTab.razor gains draggable="true" on every line row with @ondragstart capturing the LineId into _dragLineId, and every area row is a drop target (@ondragover with :preventDefault so the browser accepts drops, @ondrop kicking off OnLineDroppedAsync). Drop path loads a fresh snapshot, builds a UnsMoveOperation(Kind=LineMove, source/target cluster matching because cross-cluster is decision-#82 rejected), runs UnsImpactAnalyzer.Analyze + shows a Bootstrap modal rendered inline in the component — modal shows HumanReadableSummary + equipment/tag counts + any CascadeWarnings list. Confirm button calls MoveLineAsync with the snapshot's RevisionToken; DraftRevisionConflictException surfaces a separate red-header "Draft changed — refresh required" modal with a Reload button that re-fetches areas + lines from the DB. New DraftRevisionConflictException in UnsService.cs, co-located with the service that throws it. Five new UnsServiceMoveTests covering LoadSnapshotAsync (areas + lines + equipment counts), RevisionToken stability between two reads, RevisionToken changes on AddLineAsync, MoveLineAsync happy path reparents the line in the DB, MoveLineAsync with stale token throws DraftRevisionConflictException + leaves the DB unchanged. Admin suite 81/81 passing (was 76, +5). Admin project builds 0 errors. Task #199 captures the deferred Playwright E2E smoke — drag a line onto a different area in a real browser, assert preview modal contents, click Confirm, assert the line row shows the new area. That PR stands up a new tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests project with Playwright + WebApplicationFactory + seeded InMemory DbContext.
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 22:25:27 -04:00
543665dedd Merge pull request (#137) - DiffViewer refactor
c8a38bc57b DiffViewer refactor — 6-section plugin pattern + 1000-row cap. Closes task #156 (Phase 6.4 Stream C). Replaces the flat single-table rendering that mixed Namespace/DriverInstance/Equipment/Tag rows into one untyped list with a per-section-card layout that makes draft review actually scannable on non-trivial diffs. New DiffSection.razor reusable component encapsulates the per-section rendering — card header shows Title + Description + a three-badge summary (+added / −removed / ~modified plus a "no changes" grey badge when the section is empty) so operators can glance at a six-card page and see what areas of the draft actually shifted before drilling into any one table. Hard row-cap at DefaultRowCap=1000 per section lives inside the component so a pathological draft (e.g. 20k tags churned by a block rebuild) can't freeze the browser on render — excess rows are silently dropped with a yellow warning banner that surfaces "Showing the first 1000 of N rows" + a pointer to run sp_ComputeGenerationDiff directly for the full set. Body max-height: 400px + overflow-y: auto gives each section its own scroll region so one big section doesn't push the others off screen. DiffViewer.razor refactored to a static Sections table driving a single foreach that instantiates one DiffSection per known TableName. Sections listed in author-order (Namespace → DriverInstance → Equipment → Tag → UnsLine → NodeAcl) — six entries matching the task acceptance criterion. The first four correspond to what sp_ComputeGenerationDiff currently emits; the last two (UnsLine + NodeAcl) render as empty "no changes" cards today + will light up when the proc is extended (tracked in task #196 for NodeAcl; UnsLine proc extension is a natural follow-up since UnsImpactAnalyzer already tracks UNS moves). RowsFor(tableName) replaces the prior flat table — each section filters the overall DiffRow list by its TableName so the proc output format stays stable. Header-bar summary at the top of the page now reads "N rows across M of 6 sections" so operators see overall change weight at a glance before scanning. Two Razor-specific fixes landed along the way: loop variable renamed from section to sec because @section collides with the Razor section directive + trips RZ2005; helper method renamed from Group to RowsFor because the Razor generator gets confused by a parameter-flowing method whose name clashes with LINQ's Group extension (the source-gen output referenced TypeCheck<T> with no argument). Admin project builds 0 errors; Admin.Tests suite 76/76 (unchanged — the refactor is structural + no service-layer logic changed, so the existing DraftValidator + EquipmentService + AdminServicesIntegrationTests cover the consuming paths). No bUnit in this project so the cap behavior isn't unit-tested at the component level; DiffSection.OnParametersSet is small + deterministic (int counts + Take(RowCap)) + reviewed before ship.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#137 2026-04-19 22:25:26 -04:00
DiffViewer refactor � 6 section cards + 1000-row cap (#156)
dohertj2 created pull request dohertj2/lmxopcua#137 2026-04-19 22:25:09 -04:00
DiffViewer refactor � 6 section cards + 1000-row cap (#156)
dohertj2 created branch diff-viewer-refactor in dohertj2/lmxopcua 2026-04-19 22:25:09 -04:00
dohertj2 pushed to diff-viewer-refactor at dohertj2/lmxopcua 2026-04-19 22:25:09 -04:00
c8a38bc57b DiffViewer refactor — 6-section plugin pattern + 1000-row cap. Closes task #156 (Phase 6.4 Stream C). Replaces the flat single-table rendering that mixed Namespace/DriverInstance/Equipment/Tag rows into one untyped list with a per-section-card layout that makes draft review actually scannable on non-trivial diffs. New DiffSection.razor reusable component encapsulates the per-section rendering — card header shows Title + Description + a three-badge summary (+added / −removed / ~modified plus a "no changes" grey badge when the section is empty) so operators can glance at a six-card page and see what areas of the draft actually shifted before drilling into any one table. Hard row-cap at DefaultRowCap=1000 per section lives inside the component so a pathological draft (e.g. 20k tags churned by a block rebuild) can't freeze the browser on render — excess rows are silently dropped with a yellow warning banner that surfaces "Showing the first 1000 of N rows" + a pointer to run sp_ComputeGenerationDiff directly for the full set. Body max-height: 400px + overflow-y: auto gives each section its own scroll region so one big section doesn't push the others off screen. DiffViewer.razor refactored to a static Sections table driving a single foreach that instantiates one DiffSection per known TableName. Sections listed in author-order (Namespace → DriverInstance → Equipment → Tag → UnsLine → NodeAcl) — six entries matching the task acceptance criterion. The first four correspond to what sp_ComputeGenerationDiff currently emits; the last two (UnsLine + NodeAcl) render as empty "no changes" cards today + will light up when the proc is extended (tracked in task #196 for NodeAcl; UnsLine proc extension is a natural follow-up since UnsImpactAnalyzer already tracks UNS moves). RowsFor(tableName) replaces the prior flat table — each section filters the overall DiffRow list by its TableName so the proc output format stays stable. Header-bar summary at the top of the page now reads "N rows across M of 6 sections" so operators see overall change weight at a glance before scanning. Two Razor-specific fixes landed along the way: loop variable renamed from section to sec because @section collides with the Razor section directive + trips RZ2005; helper method renamed from Group to RowsFor because the Razor generator gets confused by a parameter-flowing method whose name clashes with LINQ's Group extension (the source-gen output referenced TypeCheck<T> with no argument). Admin project builds 0 errors; Admin.Tests suite 76/76 (unchanged — the refactor is structural + no service-layer logic changed, so the existing DraftValidator + EquipmentService + AdminServicesIntegrationTests cover the consuming paths). No bUnit in this project so the cap behavior isn't unit-tested at the component level; DiffSection.OnParametersSet is small + deterministic (int counts + Take(RowCap)) + reviewed before ship.
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 22:16:24 -04:00
cecb84fa5d Merge pull request (#136) - Admin RedundancyTab
13d5a7968b Admin RedundancyTab — per-cluster read-only topology view. Closes the UI slice of task #149 (Phase 6.3 Stream E — Admin UI RedundancyTab + OpenTelemetry metrics + SignalR); the OpenTelemetry metrics + RoleChanged SignalR push are split into new follow-up task #198 because each is a structural add that deserves its own test matrix + NuGet-dep decision rather than riding this UI PR. New /clusters/{ClusterId} Redundancy tab slotted between ACLs and Audit in the existing ClusterDetail tab bar. Shows each ClusterNode row in the cluster with columns Node / Role (Primary green, Secondary blue, Standalone primary-blue badge) / Host / OPC UA port / ServiceLevel base / ApplicationUri (text-break so the long urn: doesn't blow out the table) / Enabled badge / Last seen (relative age via the same FormatAge helper as Hosts.razor, with a yellow "Stale" chip once LastSeenAt crosses the 30s threshold shared with HostStatusService.StaleThreshold — a missed heartbeat plus clock-skew buffer). Four summary cards above the table — total Nodes, Primary count, Secondary count, Stale count. Two guard-rail alerts: (a) red "No Primary or Standalone" when the cluster has no authoritative write target (all rows are Secondaries — read-only until one is promoted by the server-side RedundancyCoordinator apply-lease flow); (b) red "Split-brain" when >1 Primary exists — apply-lease enforcement at the coordinator level should have made this impossible, so the alert implies a hand-edited DB row + an investigation. New ClusterNodeService with ListByClusterAsync (ordered by ServiceLevelBase descending so Primary rows with higher base float to the top) + a static IsStale predicate matching HostStatusService's 30s convention. DI-registered alongside the existing scoped services in Program.cs. Writes (role swap, enable/disable) are deliberately absent from the service — they go through the RedundancyCoordinator apply-lease flow on the server side + direct DB mutation from Admin would race with it. New ClusterNodeServiceTests covering IsStale across null/recent/old LastSeenAt + ListByClusterAsync ordering + cluster filter. 4/4 new tests passing; full Admin.Tests suite 76/76 (was 72 before this PR, +4). Admin project builds 0 errors. Task #198 captures the deferred work: (1) OpenTelemetry Meter for primary/secondary/stale counts + role_transition counter with from/to/node tags + OTLP exporter config; (2) RoleChanged SignalR push — extend FleetStatusPoller to detect RedundancyRole changes on ClusterNode rows + emit a RoleChanged hub message so the RedundancyTab refreshes instantly instead of on-page-load polling.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#136 2026-04-19 22:16:22 -04:00
Admin RedundancyTab � per-cluster read-only topology (#149 UI slice)
dohertj2 created pull request dohertj2/lmxopcua#136 2026-04-19 22:16:14 -04:00
Admin RedundancyTab � per-cluster read-only topology (#149 UI slice)
dohertj2 created branch redundancy-tab in dohertj2/lmxopcua 2026-04-19 22:16:14 -04:00
dohertj2 pushed to redundancy-tab at dohertj2/lmxopcua 2026-04-19 22:16:14 -04:00
13d5a7968b Admin RedundancyTab — per-cluster read-only topology view. Closes the UI slice of task #149 (Phase 6.3 Stream E — Admin UI RedundancyTab + OpenTelemetry metrics + SignalR); the OpenTelemetry metrics + RoleChanged SignalR push are split into new follow-up task #198 because each is a structural add that deserves its own test matrix + NuGet-dep decision rather than riding this UI PR. New /clusters/{ClusterId} Redundancy tab slotted between ACLs and Audit in the existing ClusterDetail tab bar. Shows each ClusterNode row in the cluster with columns Node / Role (Primary green, Secondary blue, Standalone primary-blue badge) / Host / OPC UA port / ServiceLevel base / ApplicationUri (text-break so the long urn: doesn't blow out the table) / Enabled badge / Last seen (relative age via the same FormatAge helper as Hosts.razor, with a yellow "Stale" chip once LastSeenAt crosses the 30s threshold shared with HostStatusService.StaleThreshold — a missed heartbeat plus clock-skew buffer). Four summary cards above the table — total Nodes, Primary count, Secondary count, Stale count. Two guard-rail alerts: (a) red "No Primary or Standalone" when the cluster has no authoritative write target (all rows are Secondaries — read-only until one is promoted by the server-side RedundancyCoordinator apply-lease flow); (b) red "Split-brain" when >1 Primary exists — apply-lease enforcement at the coordinator level should have made this impossible, so the alert implies a hand-edited DB row + an investigation. New ClusterNodeService with ListByClusterAsync (ordered by ServiceLevelBase descending so Primary rows with higher base float to the top) + a static IsStale predicate matching HostStatusService's 30s convention. DI-registered alongside the existing scoped services in Program.cs. Writes (role swap, enable/disable) are deliberately absent from the service — they go through the RedundancyCoordinator apply-lease flow on the server side + direct DB mutation from Admin would race with it. New ClusterNodeServiceTests covering IsStale across null/recent/old LastSeenAt + ListByClusterAsync ordering + cluster filter. 4/4 new tests passing; full Admin.Tests suite 76/76 (was 72 before this PR, +4). Admin project builds 0 errors. Task #198 captures the deferred work: (1) OpenTelemetry Meter for primary/secondary/stale counts + role_transition counter with from/to/node tags + OTLP exporter config; (2) RoleChanged SignalR push — extend FleetStatusPoller to detect RedundancyRole changes on ClusterNode rows + emit a RoleChanged hub message so the RedundancyTab refreshes instantly instead of on-page-load polling.
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 22:02:40 -04:00
d1686ed82d Merge pull request (#135) - Equipment CSV import UI
ac69a1c39d Equipment CSV import UI — Stream B.3/B.5 operator page + EquipmentTab "Import CSV" button. Closes the UI slice of task #163 (Phase 6.4 Stream B.3/B.5); the ExternalIdReservation merge follow-up inside FinaliseBatchAsync is split into new task #197 so it gets a proper concurrent-insert test matrix rather than riding this UI PR. New /clusters/{ClusterId}/draft/{GenerationId}/import-equipment page driving the full staged-import flow end-to-end. Operator selects a driver instance + UNS line (both scoped to the draft generation via DriverInstanceService.ListAsync + UnsService.ListLinesAsync dropdowns), pastes or uploads a CSV (InputFile with 5 MiB cap so pathological files can't OOM the server), clicks Parse — EquipmentCsvImporter.Parse runs + shows two side-by-side cards (accepted rows in green with ZTag/Machine/Name/Line columns, rejected rows in red with line-number + reason). Click Stage + Finalise and the page calls CreateBatchAsync → StageRowsAsync → FinaliseBatchAsync in sequence using the authenticated user's identity as CreatedBy; on success, 600ms banner then NavigateTo back to the draft editor so operator sees the newly-imported rows in EquipmentTab without a manual refresh. Parse errors (missing version marker, bad header, malformed CSV) surface InvalidCsvFormatException.Message inline alongside the Parse button — no page reload needed to retry. Finalise errors surface the service-layer exception message (ImportBatchNotFoundException / ImportBatchAlreadyFinalisedException / any DbUpdate* exception from the atomic transaction) so operator sees exactly why the finalise rejected before the tx rolled back. EquipmentTab gains an "Import CSV…" button next to "Add equipment" that NavigateTo's the new page; it needs a ClusterId parameter to build the URL so the @code block adds [Parameter] string ClusterId, and DraftEditor now passes ClusterId="@ClusterId" alongside the existing GenerationId. EquipmentImportBatchService was already implemented in Phase 6.4 Stream B.4 but missing from the Admin DI container — this PR adds AddScoped so the @inject resolves. The FinaliseBatch docstring explicitly defers ExternalIdReservation merge as a narrower follow-up with a concurrent-insert test matrix — task #197 captures that work. For now the finalise may surface a DB-level UNIQUE-constraint violation if a ZTag conflict exists at commit time; the UI shows the raw message + the batch + staged rows are still in the DB for re-use once the conflict is resolved. Admin project builds 0 errors; Admin.Tests 72/72 passing.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#135 2026-04-19 22:02:38 -04:00
Equipment CSV import UI (#163 UI slice)
dohertj2 created branch equipment-csv-import-ui in dohertj2/lmxopcua 2026-04-19 22:02:30 -04:00
dohertj2 pushed to equipment-csv-import-ui at dohertj2/lmxopcua 2026-04-19 22:02:30 -04:00
ac69a1c39d Equipment CSV import UI — Stream B.3/B.5 operator page + EquipmentTab "Import CSV" button. Closes the UI slice of task #163 (Phase 6.4 Stream B.3/B.5); the ExternalIdReservation merge follow-up inside FinaliseBatchAsync is split into new task #197 so it gets a proper concurrent-insert test matrix rather than riding this UI PR. New /clusters/{ClusterId}/draft/{GenerationId}/import-equipment page driving the full staged-import flow end-to-end. Operator selects a driver instance + UNS line (both scoped to the draft generation via DriverInstanceService.ListAsync + UnsService.ListLinesAsync dropdowns), pastes or uploads a CSV (InputFile with 5 MiB cap so pathological files can't OOM the server), clicks Parse — EquipmentCsvImporter.Parse runs + shows two side-by-side cards (accepted rows in green with ZTag/Machine/Name/Line columns, rejected rows in red with line-number + reason). Click Stage + Finalise and the page calls CreateBatchAsync → StageRowsAsync → FinaliseBatchAsync in sequence using the authenticated user's identity as CreatedBy; on success, 600ms banner then NavigateTo back to the draft editor so operator sees the newly-imported rows in EquipmentTab without a manual refresh. Parse errors (missing version marker, bad header, malformed CSV) surface InvalidCsvFormatException.Message inline alongside the Parse button — no page reload needed to retry. Finalise errors surface the service-layer exception message (ImportBatchNotFoundException / ImportBatchAlreadyFinalisedException / any DbUpdate* exception from the atomic transaction) so operator sees exactly why the finalise rejected before the tx rolled back. EquipmentTab gains an "Import CSV…" button next to "Add equipment" that NavigateTo's the new page; it needs a ClusterId parameter to build the URL so the @code block adds [Parameter] string ClusterId, and DraftEditor now passes ClusterId="@ClusterId" alongside the existing GenerationId. EquipmentImportBatchService was already implemented in Phase 6.4 Stream B.4 but missing from the Admin DI container — this PR adds AddScoped so the @inject resolves. The FinaliseBatch docstring explicitly defers ExternalIdReservation merge as a narrower follow-up with a concurrent-insert test matrix — task #197 captures that work. For now the finalise may surface a DB-level UNIQUE-constraint violation if a ZTag conflict exists at commit time; the UI shows the raw message + the batch + staged rows are still in the DB for re-use once the conflict is resolved. Admin project builds 0 errors; Admin.Tests 72/72 passing.
dohertj2 created pull request dohertj2/lmxopcua#135 2026-04-19 22:02:29 -04:00
Equipment CSV import UI (#163 UI slice)
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 21:48:17 -04:00
30714831fa Merge pull request (#134) - Admin RoleGrants page
44d4448b37 Admin RoleGrants page — LDAP-group → Admin-role mapping CRUD. Closes the RoleGrantsTab slice of task #144 (Phase 6.2 Stream D follow-up); the remaining three sub-items (Probe-this-permission on AclsTab, SignalR invalidation on role/ACL changes, draft-diff ACL section) are split into new follow-up task #196 so each can ship independently. The permission-trie evaluator + ILdapGroupRoleMappingService already exist from Phase 6.2 Streams A + B — this PR adds the consuming UI + the DI registration that was missing. New /role-grants page at Components/Pages/RoleGrants.razor registered in MainLayout's sidebar next to Certificates. Lists every LdapGroupRoleMapping row with columns LDAP group / Role / Scope (Fleet-wide or Cluster:X) / Created / Notes / Revoke. Add-grant form takes LDAP group DN + AdminRole dropdown (ConfigViewer, ConfigEditor, FleetAdmin) + Fleet-wide checkbox + Cluster dropdown (disabled when Fleet-wide checked) + optional Notes. Service-layer invariants — IsSystemWide=true + ClusterId=null, or IsSystemWide=false + ClusterId populated — enforced in ValidateInvariants; UI catches InvalidLdapGroupRoleMappingException and displays the message in a red alert. ILdapGroupRoleMappingService was present in the Configuration project from Stream A but never registered in the Admin DI container — this PR adds the AddScoped registration so the injection can resolve. Control-plane/data-plane separation note rendered in an info banner at the top of the page per decision #150 (these grants do NOT govern OPC UA data-path authorization; NodeAcl rows are read directly by the permission-trie evaluator without consulting role mappings). Admin project builds 0 errors; Admin.Tests 72/72 passing. Task #196 created to track: (1) AclsTab Probe-this-permission form that takes (ldap group, node path, permission flag) and runs it through the permission trie, showing which row granted it + the actual resolved grant; (2) SignalR invalidation — push a RoleGrantsChanged event when rows are created/deleted so connected Admin sessions reload without polling, ditto NodeAclChanged on ACL writes; (3) DiffViewer ACL section — show NodeAcl + LdapGroupRoleMapping deltas between draft + published alongside equipment/uns diffs.
Compare 2 commits »