Commit Graph

470 Commits

Author SHA1 Message Date
Joseph Doherty
e099ed2038 feat(centralui): TreeView checkbox-selection mode with tri-state 2026-05-24 05:13:04 -04:00
Joseph Doherty
cda80cf821 fix(transport): robust failure-audit when rollback throws + doc clarifications
Address one Blocker and three Important findings from code review of
2c34f12 (BundleImporter.ApplyAsync):

- BLOCKER: wrap RollbackAsync in nested try/catch so a rollback fault
  does not swallow the BundleImportFailed audit row. Dispose the
  failed transaction before the audit-write so the new SaveChangesAsync
  uses a fresh implicit transaction instead of enlisting in the broken
  one. Surface the rollback exception's message on the failure row
  alongside the original cause, and swallow audit-write faults per the
  design's best-effort-audit invariant. Add regression integration
  test using a SQLite transaction interceptor that throws on rollback.

- Document re-entrancy assumption on IAuditCorrelationContext: scoped
  lifetime, single circuit, concurrent imports within a shared scope
  must serialize externally.

- Document repository audit responsibility on BundleImporter: repos
  are thin EF wrappers; ApplyAsync writes audit rows explicitly. If
  repos ever start emitting audit rows, the explicit calls here must
  be removed to avoid double-logging.

- Document BundleSessionStore thread-safety: ConcurrentDictionary
  primitives are safe under concurrent callers; BundleSession itself
  is not thread-safe.
2026-05-24 05:06:04 -04:00
Joseph Doherty
2c34f12a6f feat(transport): BundleImporter.ApplyAsync transactional with audit correlation 2026-05-24 04:55:43 -04:00
Joseph Doherty
2400249453 feat(transport): BundleImporter.PreviewAsync diff engine 2026-05-24 04:41:24 -04:00
Joseph Doherty
5fc6790c36 feat(transport): BundleImporter.LoadAsync with manifest validation 2026-05-24 04:37:02 -04:00
Joseph Doherty
7c70ce0dbf feat(transport): BundleExporter with audit logging 2026-05-24 04:30:18 -04:00
Joseph Doherty
901d9affdf feat(transport): in-memory BundleSessionStore with TTL + lockout 2026-05-24 04:20:55 -04:00
Joseph Doherty
06c2b20178 feat(transport): DependencyResolver with topological closure 2026-05-24 04:19:23 -04:00
Joseph Doherty
550ab0e034 feat(transport): BundleSerializer ZIP packer/reader 2026-05-24 04:11:11 -04:00
Joseph Doherty
ee76b84b0f feat(transport): bundle entity DTOs + secret carving in EntitySerializer 2026-05-24 04:08:43 -04:00
Joseph Doherty
447bf84b13 feat(transport): ManifestBuilder + ManifestValidator with schema-version gating 2026-05-24 04:04:58 -04:00
Joseph Doherty
dc669a119b feat(transport): AES-256-GCM + PBKDF2 BundleSecretEncryptor 2026-05-24 04:03:44 -04:00
Joseph Doherty
7e51274812 feat(transport): scaffold ScadaLink.Transport project + test projects 2026-05-24 03:57:07 -04:00
Joseph Doherty
f32b59a557 feat(transport): AuditService stamps BundleImportId from correlation context 2026-05-24 03:55:17 -04:00
Joseph Doherty
d630e2646b feat(ui): show SourceNode under SourceSiteId in audit log detail popup
The audit log drilldown drawer (and the execution-tree node-detail modal,
which shares this component) now renders the SourceNode field directly
under SourceSiteId so provenance reads 'site → node → instance → script'
in declared order. Two focused tests pin the field's presence in both
populated and null cases plus the inter-field ordering.
2026-05-23 19:01:48 -04:00
Joseph Doherty
c754666a3d fix(ui): carry SourceNode on SiteCallDetail + NotificationDetail records
The Site Calls and Notifications detail modals were reading SourceNode from
the summary record (d.SourceNode) while every other field read from the
detail record (det.X). The pattern works today because the modal always
opens via a row click that pre-loads the summary, but a future drill-in
from a deep link or refresh path could leave the summary stale or null and
the field would render blank or wrong.

Add SourceNode to both detail records, project it through the actor's
ToDetail mapping, and switch the razor markup to read det.SourceNode. Now
the modal binds uniformly to the detail record across all fields.
2026-05-23 18:37:53 -04:00
Joseph Doherty
d18a6e6fa0 feat(ui): add Node column + filter to SiteCalls grid 2026-05-23 18:08:25 -04:00
Joseph Doherty
b9c017136d feat(ui): add Node column + filter to NotificationOutbox grid 2026-05-23 18:04:59 -04:00
Joseph Doherty
bb29d65a94 feat(ui): add Node column + filter to AuditLog grid 2026-05-23 18:01:36 -04:00
Joseph Doherty
466e1454fe test(sitecall-audit): symmetric SourceNode coverage on DbOutbound emitter + clarify DI comments
Two follow-ups from the T13/T14 code review:

- M1: Add CachedWrite_StampsSourceNode_OnSubmitTelemetryRow and
  CachedWrite_NoSourceNodeWired_LeavesSourceNodeNull to DatabaseCachedWriteEmissionTests,
  mirroring the existing ApiOutbound SourceNode tests in
  ExternalSystemCachedCallEmissionTests. Site-emitter coverage now symmetric
  across both cached-call channels.
- M2: Clarify the GetService(INodeIdentityProvider) DI comments on the
  CachedCallTelemetryForwarder and CachedCallLifecycleBridge factories:
  it's test composition roots that may not register the provider, not
  central production. Both site and central hosts always register it via
  SiteServiceRegistration.BindSharedOptions.
2026-05-23 17:50:14 -04:00
Joseph Doherty
06ed0acead feat(sitecall-audit): carry + persist SourceNode end-to-end via cached telemetry
Site: site emitters of SiteCallOperational (ExternalSystemClient, the script-API
cached call path in ScriptRuntimeContext, CachedCallLifecycleBridge) inject
INodeIdentityProvider and stamp SourceNode = NodeName at construction.

OperationTrackingStore call site in CachedCallTelemetryForwarder now stamps
SourceNode too.

Central: SiteCallAuditRepository.UpsertAsync INSERT includes SourceNode in the
column list; conditional monotonic UPDATE uses
COALESCE(@SourceNode, SourceNode) so later packets cannot blank a previously-
stamped value. After this commit every SiteCalls row carries node-a/node-b in
SourceNode (subject to monotonic preservation).
2026-05-23 17:41:22 -04:00
Joseph Doherty
d1fcab490c feat(notif-outbox): carry + persist SourceNode end-to-end via NotificationSubmit
Site: inject INodeIdentityProvider where NotificationSubmit is built; stamp
SourceNode = NodeName at construction.

Central: NotificationOutboxActor.HandleSubmit copies submit.SourceNode onto
the Notification row; the repository INSERT persists it (EF tracked-entity
insert flows it through automatically; raw-SQL extension if not).

After this commit, every Notifications row carries the originating site
node-a/node-b in SourceNode. Existing notifications submitted pre-feature
remain NULL.
2026-05-23 17:28:23 -04:00
Joseph Doherty
e6341580b3 test(audit): lock null-provider passthrough on CentralAuditWriter
Two follow-ups flagged by code review on Tasks 11/12:

- Lock the back-compat contract for CentralAuditWriter's optional
  `nodeIdentity = null` ctor parameter with two explicit tests
  (`WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected` and
  `WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected`). The previous
  null-provider path was only exercised incidentally via legacy
  CentralAuditWriterTests setups; the new tests make the contract explicit
  and distinct from the "provider supplied, returns null" path.

- Document why the catch-block log references `evt` rather than the
  post-stamp record: the three logged fields (EventId, Kind, Status) are
  immutable across the filter+stamp chain, so referencing either name is
  equivalent — but the comment warns future maintainers to switch names if
  they ever add a field the chain mutates (e.g. SourceNode).
2026-05-23 17:18:45 -04:00
Joseph Doherty
974a36826a feat(audit): stamp SourceNode at CentralAuditWriter + persist via AuditLogRepository
CentralAuditWriter injects INodeIdentityProvider and stamps the event before
handing to the repository. AuditLogRepository.InsertIfNotExistsAsync now
includes SourceNode in the INSERT column list. Caller-provided value wins
(supports any future direct-write callsite that already has its own node id).
2026-05-23 17:11:23 -04:00
Joseph Doherty
479870e40c feat(audit): stamp SourceNode at site SqliteAuditWriter from INodeIdentityProvider
Caller-provided SourceNode wins (preserves reconciled rows from other nodes);
otherwise the writer fills it from the local INodeIdentityProvider.NodeName.
Reads from the provider on every write — singleton lifetime means zero overhead.
2026-05-23 17:08:21 -04:00
Joseph Doherty
277882d230 feat(site-runtime): add SourceNode column to OperationTracking + thread through RecordEnqueueAsync 2026-05-23 16:54:48 -04:00
Joseph Doherty
f3cb8c0791 feat(audit): add SourceNode column to site SQLite AuditLog (idempotent upgrade) 2026-05-23 16:50:16 -04:00
Joseph Doherty
1a77bc5f38 feat(db): add SourceNode column to SiteCalls 2026-05-23 16:34:30 -04:00
Joseph Doherty
16b685b96b feat(db): add SourceNode column to Notifications 2026-05-23 16:34:30 -04:00
Joseph Doherty
552d7832a3 feat(db): add SourceNode column + IX_AuditLog_Node_Occurred index to AuditLog 2026-05-23 16:34:28 -04:00
Joseph Doherty
dfaa416ebe feat(comm): add source_node field to AuditEventDto + SiteCallOperationalDto proto
- AuditEventDto field 22, SiteCallOperationalDto field 12. Both follow the
  existing empty-string-means-null convention.
- Mappers carry SourceNode end-to-end; round-trip tests cover both populated
  and null cases.
2026-05-23 16:10:03 -04:00
Joseph Doherty
990eb02fe0 feat(sitecall-audit): add SourceNode to SiteCallOperational + SiteCall entity 2026-05-23 15:53:44 -04:00
Joseph Doherty
354f8792bf feat(notif-outbox): add SourceNode to Notification entity + NotificationSubmit 2026-05-23 15:46:30 -04:00
Joseph Doherty
ad625eb36d feat(audit): add SourceNode property to AuditEvent record 2026-05-23 15:45:31 -04:00
Joseph Doherty
2e10cbe42d feat(host): add NodeName to NodeOptions + INodeIdentityProvider
- NodeName: semantic role-within-cluster identifier (node-a/node-b on sites,
  central-a/central-b on central). Bound from ScadaLink:Node:NodeName.
- INodeIdentityProvider exposes the trimmed name (null if unconfigured) so
  downstream audit writers can stamp the new SourceNode column.
2026-05-23 15:38:27 -04:00
Joseph Doherty
7d87994ac0 feat(inboundapi): bound audit capture at InboundMaxBytes (memory safety)
AuditWriteMiddleware previously buffered the FULL request and response
bodies into memory and only let DefaultAuditPayloadFilter trim them
after persistence. A 500 MiB upload allocated 500 MiB of MemoryStream
plus 1 GiB of UTF-16 string transiently before the filter pulled it
back to the 1 MiB inbound ceiling — the cap was real on the persisted
row but not at the capture site.

Inject IOptionsMonitor<AuditLogOptions> and read InboundMaxBytes
per-request (same convention as DefaultAuditPayloadFilter so a live
config change picks up the next request). The request reader now pulls
at most cap + 1 bytes into a UTF-8 byte-safe-truncated string and
rewinds the stream so the endpoint handler still sees the full body.
The response wrap is a new CapturedResponseStream that forwards every
Write / WriteAsync to the real sink (the client still receives all
bytes) while capturing at most cap + 1 bytes for the audit copy. The
middleware now sets PayloadTruncated itself when either body hit the
cap; the filter still OR's its own determination on top.

Adds a project reference from ScadaLink.InboundAPI to
ScadaLink.AuditLog so AuditLogOptions resolves. AuditLog does NOT
reference InboundAPI back, so no cycle is introduced.

Tests:
 - All 21 existing AuditWriteMiddlewareTests still pass (the helper
   gains an optional AuditLogOptions argument; default is the standard
   1 MiB ceiling so existing small-body tests are unaffected).
 - MiddlewareOrderTests' construction site updated for the new ctor
   arg; a StaticAuditLogOptionsMonitor file-local double mirrors the
   InboundChannelCapTests pattern.
 - New RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue
   pins a 4 KiB cap against a 20 KB body: audit copy <= 4 KiB,
   PayloadTruncated = true, downstream handler reads the full 20 KB.
 - New ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue
   pins the same shape on the response side: client sink receives
   20 KB, audit copy <= 4 KiB, PayloadTruncated = true.

InboundAPI test count: 133 -> 135.
2026-05-23 09:25:00 -04:00
Joseph Doherty
a8d2e13d4e feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows 2026-05-23 06:00:24 -04:00
Joseph Doherty
7b619d711d feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows 2026-05-23 05:55:03 -04:00
Joseph Doherty
c5b27361c0 feat(auditlog): add AuditLog:InboundMaxBytes option (default 1 MiB, [8 KiB, 16 MiB]) 2026-05-23 05:39:50 -04:00
Joseph Doherty
bb6f6aaa54 test(centralui): fix flaky audit-grid resize-survives-reload test
ResizeHandle_DraggingWidensColumn_AndSurvivesReload called page.ReloadAsync()
immediately after the resize drag, racing the asynchronous persist: pointer-up
fires a fire-and-forget JS→.NET OnColumnResized invoke that round-trips back
through JS interop to write sessionStorage. When the reload won the race the
restored grid fell back to the default column width and the test failed
(~1 in 3 runs).

Wait for auditGrid:columnWidths to land via the existing WaitForStorageKeyAsync
helper before reloading — the same guard the sibling
ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage test already uses.
Verified: 6/6 consecutive passes.
2026-05-22 08:00:46 -04:00
Joseph Doherty
86ee7bd1a8 feat(centralui): collapsible sidebar nav sections
Make the seven sidebar section groups (Admin, Design, Deployment,
Notifications, Site Calls, Monitoring, Audit) collapsible. New NavSection
component renders a header toggle button (chevron) and reveals its items
only while expanded; NavMenu owns the expanded-section set.

Behaviour: sections are collapsed by default; state persists in the
`scadabridge_nav` cookie (written/read via the new nav-state.js JS interop,
mirroring treeview-storage.js) so it survives reloads and reconnects;
navigating into a section auto-expands it and remembers it. The Dashboard
item stays sectionless and always visible.

Tests: NavMenu bUnit tests expand sections before asserting items and add
collapsed-by-default / toggle / cookie-persistence cases; Playwright nav
tests expand sections before clicking links; new NavCollapseTests covers
the feature E2E. Build 0 warnings; bUnit 545 passed; Playwright nav suite
green (the unrelated AuditGridColumnTests resize-reload case remains
pre-existing flaky — an un-awaited save race in that test).
2026-05-22 07:36:57 -04:00
Joseph Doherty
b07f43a308 feat(centralui): rebrand web UI to ScadaBridge + technical-light theme
Rename the user-facing product name from ScadaLink to ScadaBridge across
the six display strings (browser title, sidebar brand, login + not-authorized
headings, dashboard welcome/subtitle). Namespaces, assemblies, config keys,
and _content/ScadaLink.CentralUI asset routes are unchanged.

Apply the technical-light design system: vendor theme.css + IBM Plex fonts
into the CentralUI RCL, include theme.css globally (after Bootstrap so its
--bs-* token overrides win), and restyle the layout chrome to a light
sidebar — white surface, hairline rules, ink text, accent-blue active item,
the brand accent mark. Page markup stays Bootstrap and inherits the warm
paper background, Plex type, accent, and hairline borders via the tokens.

Tests: build 0 warnings; bUnit 542 passed; Playwright 64 passed.
2026-05-22 07:03:46 -04:00
Joseph Doherty
d4a7344f89 docs(centralui): refresh stale test summaries + drop redundant modal-lg 2026-05-22 02:03:36 -04:00
Joseph Doherty
35cef4ad1b test(centralui): e2e execution-tree node detail modal + docs 2026-05-22 01:54:12 -04:00
Joseph Doherty
3f1ad08f42 feat(centralui): open ExecutionDetailModal on tree-node double-click 2026-05-22 01:46:12 -04:00
Joseph Doherty
5c86983ef6 fix(centralui): Esc-to-close and aria attributes on ExecutionDetailModal 2026-05-22 01:43:41 -04:00
Joseph Doherty
386cd0b955 feat(centralui): ExecutionDetailModal — execution rows with per-row detail 2026-05-22 01:39:04 -04:00
Joseph Doherty
603995d43a feat(centralui): ExecutionTree node double-click raises OnNodeActivated 2026-05-22 01:32:37 -04:00
Joseph Doherty
6a6d0e88a7 refactor(centralui): extract AuditEventDetail from AuditDrilldownDrawer 2026-05-22 01:25:40 -04:00
Joseph Doherty
3f1c0e5018 fix(centralui): re-apply Audit Log query-string filters on same-page drill-in
The drilldown drawer's 'View this/parent execution' actions call
NavigationManager.NavigateTo('/audit/log?executionId=...') while the
user is already on the routed AuditLogPage. Blazor treats this as a
same-component navigation, so OnInitialized does not re-run and
ApplyQueryStringFilters() (which was wired only to OnInitialized) never
re-parsed the new query string: _currentFilter stayed stale and the
results grid never reloaded to the drill-in target.

AuditLogPage now subscribes to NavigationManager.LocationChanged,
re-applies the query-string filters on every location change (closing
the drawer and calling StateHasChanged), and unsubscribes via
IDisposable. The 'View parent execution' drill-in now genuinely lands
on /audit/log?executionId={parentId} with the grid reloaded.

Also corrects the Playwright test wait: a same-page query-string Blazor
navigation pushes history.pushState over the SignalR circuit rather
than triggering a document load, so WaitForLoadState(NetworkIdle)
returned before the URL settled. Switched to WaitForURLAsync, the
correct primitive for SPA/pushState navigations.
2026-05-21 20:30:48 -04:00