Branches the DriversTab driver-add form on driver type:
- For DriverType=Modbus, render the typed <ModbusOptionsEditor> component
shipped in #145 instead of the generic JSON textarea.
- For other driver types, the existing textarea stays (other drivers ship
their own typed editors per decision #94).
On Save, when type is Modbus, the form serialises ModbusOptionsViewModel
into the JSON DTO shape ModbusDriverFactoryExtensions consumes (host /
port / unitId / family / keepAlive / reconnect / max*** / writeOnChangeOnly
/ etc.). Other types still pass the textarea contents verbatim.
Drive-by fix: the DriverType dropdown listed "ModbusTcp" but the actual
factory-registered name is "Modbus" — DriverInstanceBootstrapper would
silently skip a row created with the old label because the factory lookup
would miss. Renamed to match.
Tests (2 new in ModbusOptionsViewModelTests):
- DriversTab_Serialized_Defaults_RoundTrip_Through_Factory — unedited
view-model serializes to a JSON the factory accepts; resulting
ModbusDriverOptions matches the form defaults bit-for-bit.
- DriversTab_Serializes_Edited_Values_Correctly — flipping Host / Port /
UnitId / Family / MaxReadGap / WriteOnChangeOnly in the view model
surfaces in the constructed driver's options.
The serializer in the test mirrors DriversTab.razor's SerializeModbusOptions
helper. If the form's serialization shape drifts, both must be updated
together; that's the cost of testing through the JSON DTO without bUnit.
Follow-up still open: the per-tag editor (ModbusAddressEditor wiring into
EquipmentTab.razor + the bulk-import help-text update) — that's a separate
surface that touches the equipment-row CRUD flow; covered as a follow-up
when the equipment tag editor surface is next touched.
Two new Blazor components surface every Modbus knob added by #136-#144 so
users can configure the driver without hand-editing DriverConfig JSON.
ModbusAddressEditor.razor (live address-string parser preview):
- Bound to a string AddressString + a Family / MelsecSubFamily hint.
- On every input keystroke, runs ModbusAddressParser.TryParse and surfaces
the resolved breakdown (Region, Offset, DataType, Bit, ByteOrder,
ArrayCount, StringLength) inline as a green badge.
- On parse error, shows the parser's diagnostic in red.
- Re-uses the SAME parser the wire driver uses — grammar drift is
impossible by construction.
ModbusOptionsEditor.razor (driver-instance options panel):
- Connection group (Host / Port / UnitId).
- Family group (#144) with conditional MelsecSubFamily dropdown.
- Keep-alive group (#139): Enabled / Time / Interval / RetryCount.
- Reconnect group (#139): InitialDelay / MaxDelay / BackoffMultiplier.
- Protocol group (#140): MaxRegistersPerRead / Write / Coils / ReadGap.
- Behaviour toggles (#140 + #141): UseFC15 / UseFC16 / WriteOnChangeOnly.
- Bound to ModbusOptionsViewModel — defaults match ModbusDriverOptions
defaults so unedited rows produce the historical wire output verbatim.
Architecture:
- Admin project gains a ProjectReference to Driver.Modbus.Addressing
(the shared parser assembly extracted in #136). Admin does NOT take a
dep on Driver.Modbus itself — the addressing concerns are cleanly
separated from the wire driver.
- Same-namespace shared assembly means components reference
ModbusAddressParser / ModbusFamily / etc. without prefix gymnastics.
Tests:
- ModbusOptionsViewModelTests (1 test) — pins every default in the view
model against the corresponding ModbusDriverOptions default. A
regression that flips an unedited row to a non-default value gets
caught here. (Test references both Admin and Driver.Modbus to make the
cross-assembly comparison.)
- Live Blazor component testing requires bUnit, which isn't currently
in the test setup; the parser logic the component wraps is fully
covered by the 91 ModbusAddressParser tests in the addressing project,
so the glue layer's behaviour is verifiable end-to-end already.
Caveat: the wiring into the existing DriverInstance edit page lives in
DriversTab.razor — that integration is left as a follow-up because it
touches the cluster-edit workflow specifically and the components in
this commit are framework-agnostic enough to drop in. The components
build clean against the existing Admin project; no behavioural change
to other tabs.
Catch-all commit for pending work on the task-galaxy-e2e branch that
wasn't part of the FOCAS migration. Grouping by topic so future per-topic
commits can be cherry-picked if needed.
TwinCAT
- src/.../Driver.TwinCAT/AdsTwinCATClient.cs + TwinCATDriverFactoryExtensions.cs:
factory-registration extensions + ADS client refinements.
- src/.../Driver.TwinCAT.Cli/Commands/BrowseCommand.cs: new browse command
for the TwinCAT test-client CLI.
- tests/.../Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs + TwinCatProject/:
fixture scaffold with a minimal POU + README pointing at the TCBSD/ESXi
VM for e2e.
- docs/Driver.TwinCAT.Cli.md + docs/drivers/TwinCAT-Test-Fixture.md:
documentation for the above.
- docs/v3/twincat-backlog.md: forward-looking backlog seed.
Admin UI + fleet status
- src/.../Admin/Components/Pages/Clusters/DriversTab.razor + Hosts.razor:
UI refresh for fleet-status rendering.
- src/.../Admin/Hubs/FleetStatusHub.cs + FleetStatusPoller.cs +
Admin/Program.cs: SignalR hub + poller plumbing for live fleet data.
- tests/.../Admin.Tests/FleetStatusPollerTests.cs: poller coverage.
Server + redundancy runtime (Phase 6.3 follow-ups)
- src/.../Server/Hosting/RedundancyPublisherHostedService.cs: HostedService
that owns the RedundancyStatePublisher lifecycle + wires peer reachability.
- src/.../Server/Redundancy/ServerRedundancyNodeWriter.cs: OPC UA
variable-node writer binding ServiceLevel + ServerUriArray to the
publisher's events.
- src/.../Server/Program.cs + Server.csproj: hosted-service registration.
- tests/.../Server.Tests/ServerRedundancyNodeWriterTests.cs +
Server.Tests.csproj: coverage for the above.
Configuration
- src/.../Configuration/Validation/DraftValidator.cs +
tests/.../Configuration.Tests/DraftValidatorTests.cs: draft-validation
refinements.
E2E scripts (shared infrastructure)
- scripts/e2e/README.md + _common.ps1 + test-all.ps1: shared helpers + the
all-drivers test-all runner.
- scripts/e2e/test-opcuaclient.ps1: OPC UA Client e2e runner.
Docs
- docs/v2/implementation/phase-6-{1,2,3,4}*.md + exit-gate-phase-{3,7}.md:
phase-gate + implementation doc updates.
- docs/v2/plan.md: top-level plan refresh.
- docs/v2/redundancy-interop-playbook.md: client interop playbook for the
Phase 6.3 redundancy-runtime work.
Two orphan FOCAS docs remain on disk but deliberately unstaged —
docs/v2/focas-deployment.md and docs/v2/implementation/focas-simulator-plan.md
describe the now-retired Tier-C topology and should either be rewritten
or deleted in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings seven FOCAS-related files into git that shipped as part of earlier
FOCAS work but were never staged. Adding them now so the tree reflects the
compilable state + pre-empts dead references from the migration commit that
follows:
- src/.../Driver.FOCAS/FocasAlarmProjection.cs — raise/clear diffing + severity
mapping surfaced via IAlarmSource on FocasDriver. Referenced by committed
FocasDriver.cs; tests in FocasAlarmProjectionTests.cs.
- src/.../Admin/Services/FocasDriverDetailService.cs — Admin UI per-instance
detail page data source.
- src/.../Admin/Components/Pages/Drivers/FocasDetail.razor — Blazor page
rendering the above (from task #69).
- tests/.../Admin.Tests/FocasDriverDetailServiceTests.cs — exercises the
detail service.
- tests/.../Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs — raise/clear
diff semantics against FakeFocasClient.
- tests/.../Driver.FOCAS.Tests/FocasHandleRecycleTests.cs — proactive recycle
cadence test.
- docs/v2/implementation/focas-wire-protocol.md — captured FOCAS/2 Ethernet
wire protocol reference. Useful going forward even though the Tier-C /
simulator plan docs are historical.
No runtime behaviour change — these files compile today and the solution
build/test pass already depends on them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the draft-editor tab + page surface for authoring Phase 7 virtual tags and
scripted alarms, plus the /alarms/historian operator diagnostics page. Monaco loads
from CDN via a progressive-enhancement JS shim — the textarea works immediately so
the page is functional even if the CDN is unreachable.
## New services (Admin)
- ScriptService — CRUD for Script entity. SHA-256 SourceHash recomputed on save so
Core.Scripting's CompiledScriptCache hits on re-publish of unchanged source + misses
when the source actually changes.
- VirtualTagService — CRUD for VirtualTag, with Enabled toggle.
- ScriptedAlarmService — CRUD for ScriptedAlarm + lookup of persistent ScriptedAlarmState
(logical-id-keyed per plan decision #14).
- ScriptTestHarnessService — pre-publish dry-run. Enforces plan decision #22: only
inputs the DependencyExtractor identifies can be supplied. Missing / extra synthetic
inputs surface as dedicated outcomes. Captures SetVirtualTag writes + Serilog events
from the script so the operator can see both the output + the log output before
publishing.
- HistorianDiagnosticsService — surfaces the local-process IAlarmHistorianSink state
on /alarms/historian. Null sink reports Disabled + swallows retry. Live
SqliteStoreAndForwardSink reports real queue depth + last-error + drain state and
routes the Retry-dead-lettered button through.
## New UI
- ScriptsTab.razor (inside DraftEditor tabs) — list + create/edit/delete scripts with
Monaco editor + dependency preview + test-harness run panel showing output + writes
+ log emissions.
- ScriptEditor.razor — reusable Monaco-backed textarea. Loads editor from CDN via
wwwroot/js/monaco-loader.js. Textarea stays authoritative for Blazor binding; Monaco
mirrors into it on every keystroke.
- AlarmsHistorian.razor (/alarms/historian) — queue depth + dead-letter depth + drain
state badge + last-error banner + Retry-dead-lettered button.
- DraftEditor.razor — new "Scripts" tab.
## DI wiring
All five services registered in Program.cs. Null historian sink bound at Admin
composition time (real SqliteStoreAndForwardSink lives in the Server process).
## Tests — 13/13
Phase7ServicesTests covers:
- ScriptService: Add generates logical id + hash, Update recomputes hash on source
change, Update same-source keeps hash (cache-hit preservation), Delete is idempotent
- VirtualTagService: round-trips trigger flags, Enabled toggle works
- ScriptedAlarmService: HistorizeToAveva defaults true per plan decision #15
- ScriptTestHarness: successful run captures output + writes, rejects missing /
extra inputs, rejects non-literal paths, compile errors surface as Threw
- HistorianDiagnosticsService: null sink reports Disabled + retry returns 0
Closes the server-side/data-layer piece of Phase 6.4 Stream B.2-B.4. The
CSV-import preview + modal UI (Stream B.3/B.5) still belongs to the Admin
UI follow-up — this PR owns the staging tables + atomic finalise alone.
Configuration:
- New EquipmentImportBatch entity (Id, ClusterId, CreatedBy, CreatedAtUtc,
RowsStaged/Accepted/Rejected, FinalisedAtUtc?). Composite index on
(CreatedBy, FinalisedAtUtc) powers the Admin preview modal's "my open
batches" query.
- New EquipmentImportRow entity — one row per CSV row, 8 required columns
from decision #117 + 9 optional from decision #139 + IsAccepted flag +
RejectReason. FK to EquipmentImportBatch with cascade delete so
DropBatch collapses the whole tree.
- EF migration 20260419_..._AddEquipmentImportBatch.
- SchemaComplianceTests expected tables list gains the two new tables.
Admin.Services.EquipmentImportBatchService:
- CreateBatchAsync — new header row, caller-supplied ClusterId + CreatedBy.
- StageRowsAsync(batchId, acceptedRows, rejectedRows) — bulk-inserts the
parsed CSV rows into staging. Rejected rows carry LineNumberInFile +
RejectReason for the preview modal. Throws when the batch is finalised.
- DropBatchAsync — removes batch + cascaded rows. Throws when the batch
was already finalised (rollback via staging is not a time machine).
- FinaliseBatchAsync(batchId, generationId, driverInstanceId, unsLineId) —
atomic apply. Opens an EF transaction when the provider supports it
(SQL Server in prod; InMemory in tests skips the tx), bulk-inserts
every accepted staging row into Equipment, stamps
EquipmentImportBatch.FinalisedAtUtc, commits. Failure rolls back so
Equipment never partially mutates. Idempotent-under-double-call:
second finalise throws ImportBatchAlreadyFinalisedException.
- ListByUserAsync(createdBy, includeFinalised) — the Admin preview modal's
backing query. OrderByDescending on CreatedAtUtc so the most-recent
batch shows first.
- Two exception types: ImportBatchNotFoundException +
ImportBatchAlreadyFinalisedException.
ExternalIdReservation merging (ZTag + SAPID fleet-wide uniqueness) is NOT
done here — a narrower follow-up wires it once the concurrent-insert test
matrix is green.
Tests (10 new EquipmentImportBatchServiceTests, all pass):
- CreateBatch populates Id + CreatedAtUtc + zero-ed counters.
- StageRows accepted + rejected both persist; counters advance.
- DropBatch cascades row delete.
- DropBatch after finalise throws.
- Finalise translates accepted staging rows → Equipment under the target
GenerationId + DriverInstanceId + UnsLineId.
- Finalise twice throws.
- Finalise of unknown batch throws.
- Stage after finalise throws.
- ListByUserAsync filters by creator + finalised flag.
- Drop of unknown batch is a no-op (idempotent rollback).
Full solution dotnet test: 1235 passing (was 1225, +10). Pre-existing
Client.CLI Subscribe flake unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the non-UI piece of Stream D: a draft-aware write surface over NodeAcl
that enforces the Phase 6.2 plan's scope-uniqueness + grant-shape invariants.
Blazor UI pieces (RoleGrantsTab + AclsTab refresh + SignalR invalidation +
visual-compliance reviewer signoff) are deferred to the Phase 6.1-style
follow-up task.
Admin.Services:
- ValidatedNodeAclAuthoringService — alongside existing NodeAclService (raw
CRUD, kept for read + revoke paths). GrantAsync enforces:
* Permissions != None (decision #129 — additive only, no empty grants).
* Cluster scope has null ScopeId.
* Sub-cluster scope requires a populated ScopeId.
* No duplicate (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId)
tuple — operator updates the row instead of inserting a duplicate.
UpdatePermissionsAsync also rejects None (operator revokes via NodeAclService).
Violations throw InvalidNodeAclGrantException.
Tests (10 new in Admin.Tests/ValidatedNodeAclAuthoringServiceTests):
- Grant rejects None permissions.
- Grant rejects Cluster-scope with ScopeId / sub-cluster without ScopeId.
- Grant succeeds on well-formed row.
- Grant rejects duplicate (group, scope) in same draft.
- Grant allows same group at different scope.
- Grant allows same (group, scope) in different draft.
- UpdatePermissions rejects None.
- UpdatePermissions round-trips new flags + notes.
- UpdatePermissions on unknown rowid throws.
Microsoft.EntityFrameworkCore.InMemory 10.0.0 added to Admin.Tests csproj.
Full solution dotnet test: 1097 passing (was 1087, +10). Phase 6.2 total is
now 1087+10 = 1097; baseline 906 → +191 net across Phase 6.1 (all streams) +
Phase 6.2 (Streams A, B, C foundation, D data layer).
Stream D follow-up task tracks: RoleGrantsTab CRUD over LdapGroupRoleMapping,
AclsTab write-through + Probe-this-permission diagnostic, draft-diff ACL
section, SignalR PermissionTrieCache invalidation push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Razor page layout: two tables (Rejected / Trusted) with Subject / Issuer / Thumbprint / Valid-window / Actions columns, status banner after each action with success or warning kind ('file missing' = another admin handled it), FleetAdmin-only via [Authorize(Roles=AdminRoles.FleetAdmin)]. Each action invokes LogActionAsync which Serilog-logs the authenticated admin user + thumbprint + action for an audit trail — DB-level ConfigAuditLog persistence is deferred because its schema is cluster-scoped and cert actions are cluster-agnostic; Serilog + CertTrustService's filesystem-op info logs give the forensic trail in the meantime. Sidebar link added to MainLayout between Reservations and the future Account page.
Tests — CertTrustServiceTests (9 new unit cases): ListRejected parses Subject + Thumbprint + store kind from a self-signed test cert written into rejected/certs/; rejected and trusted stores are kept separate; TrustRejected moves the file and the Rejected list is empty afterwards; TrustRejected with a thumbprint not in rejected returns false without touching trusted; DeleteRejected removes the file; UntrustCert removes from trusted only; thumbprint match is case-insensitive (operator UX); missing store directories produce empty lists instead of throwing DirectoryNotFoundException (pristine-install tolerance); a junk .der in the store is logged + skipped and the valid certs still surface (one bad file doesn't break the page). Full Admin.Tests Unit suite: 23 pass / 0 fail (14 prior + 9 new). Full Admin build clean — 0 errors, 0 warnings.
lmx-followups.md #3 marked DONE with a cross-reference to this PR and a note that flipping AutoAcceptUntrustedClientCertificates to false as the production default is a deployment-config follow-up, not a code gap — the Admin UI is now ready to be the trust gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>