62 Commits

Author SHA1 Message Date
Joseph Doherty 5198b114b4 fix(auditlog): evolve existing site auditlog.db schema for ExecutionId 2026-05-21 16:18:17 -04:00
Joseph Doherty fd76c19007 test(auditlog): end-to-end ExecutionId correlation + docs 2026-05-21 16:06:40 -04:00
Joseph Doherty 24cdfe373c feat(audit): ExecutionId filter in the CLI and ManagementService 2026-05-21 16:00:09 -04:00
Joseph Doherty 1ba62052d6 feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page 2026-05-21 15:52:57 -04:00
Joseph Doherty cfd8f1ecf4 feat(auditlog): inbound audit rows carry ExecutionId 2026-05-21 15:44:17 -04:00
Joseph Doherty 6aac4c8ed7 test(auditlog): pin OriginExecutionId preservation in forwarder + Parked NotifyDeliver 2026-05-21 15:42:45 -04:00
Joseph Doherty 85bb61a1f3 feat(auditlog): NotifyDeliver rows carry the originating ExecutionId 2026-05-21 15:35:40 -04:00
Joseph Doherty 705ae95404 test(auditlog): assert ExecutionId threading hops; defensive Guid parse on S&F read 2026-05-21 15:27:58 -04:00
Joseph Doherty 6f5a35f222 feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows
The store-and-forward retry loop emits the per-attempt and terminal cached
audit rows (ApiCallCached/DbWriteCached Attempted, CachedResolve) via
CachedCallLifecycleBridge from a CachedCallAttemptContext, not from the
script context. ExecutionId (and SourceScript) were not threaded through the
S&F buffer, so those rows had ExecutionId = null and SourceScript = null.

Thread both, additively, from the cached-call enqueue path:

- StoreAndForwardMessage gains ExecutionId (Guid?) / SourceScript (string?).
- StoreAndForwardStorage adds nullable execution_id / source_script columns
  via an idempotent PRAGMA-probed ALTER TABLE migration; rows persisted by
  an older build read back null (back-compat).
- StoreAndForwardService.EnqueueAsync gains optional executionId /
  sourceScript params, stamped onto the buffered message and surfaced on the
  CachedCallAttemptContext built in the retry loop.
- CachedCallAttemptContext gains ExecutionId / SourceScript.
- CachedCallLifecycleBridge.BuildPacket sets AuditEvent.ExecutionId and
  AuditEvent.SourceScript from the context (replacing the hard-coded
  SourceScript = null and its now-stale comment).
- IExternalSystemClient.CachedCallAsync / IDatabaseGateway.CachedWriteAsync
  gain optional executionId / sourceScript params; ScriptRuntimeContext's
  CachedCall / CachedWrite helpers pass _executionId / _sourceScript.

Script-side cached rows (CachedSubmit, immediate Attempted+Resolve) are
unchanged. All threading is additive — old buffered S&F rows still
deserialize and process with the new fields null.
2026-05-21 15:18:35 -04:00
Joseph Doherty 0149ce6180 feat(auditlog): site script-side emitters stamp ExecutionId
Move the per-script-execution Guid on ScriptRuntimeContext from
_auditCorrelationId to _executionId, and stamp it into the dedicated
AuditEvent.ExecutionId column on every script-side audit row:

- Sync ApiCall / DbWrite: ExecutionId set; CorrelationId reverts to
  null (a sync one-shot call has no operation lifecycle).
- Cached-call script-side rows (CachedSubmit, immediate-completion
  ApiCallCached + CachedResolve) and NotifySend: ExecutionId set;
  CorrelationId unchanged (per-operation TrackedOperationId /
  NotificationId).

Renames the threaded ctor param/field across ExternalSystemHelper,
DatabaseHelper, AuditingDbConnection and AuditingDbCommand, and threads
the id through NotifyHelper/NotifyTarget. The S&F retry-loop cached rows
(CachedCallLifecycleBridge) are out of scope here.
2026-05-21 15:05:00 -04:00
Joseph Doherty 6b16a48886 feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto 2026-05-21 14:53:08 -04:00
Joseph Doherty 990731d12f test(auditlog): cover ExecutionId in AuditEvent round-trip test; clarify staging-table comment 2026-05-21 14:48:39 -04:00
Joseph Doherty fd12021984 feat(auditlog): ExecutionId column on AuditEvent + central AuditLog 2026-05-21 14:43:35 -04:00
Joseph Doherty 4002f4197b docs(plan): Audit Log ExecutionId implementation plan 2026-05-21 14:37:12 -04:00
Joseph Doherty 6ffa47f258 docs(design): Audit Log ExecutionId universal correlation 2026-05-21 14:34:12 -04:00
Joseph Doherty c9229c35fc Merge branch 'feature/audit-execution-correlation': per-execution audit correlation id
Every script execution gets an audit correlation id (generated for tag/timer
runs, request-local for inbound); it is stamped as CorrelationId on the sync
ApiCall and DbWrite audit rows so all sync trust-boundary rows from one run
correlate. Shared scripts inherit it. Cached calls / notifications keep their
existing CorrelationId. No schema change.
2026-05-21 13:58:37 -04:00
Joseph Doherty aadb1fd72a refactor(auditlog): rename audit correlation field, add cross-helper tests 2026-05-21 13:57:17 -04:00
Joseph Doherty 8243f61e96 feat(auditlog): per-script-execution correlation id on sync audit rows 2026-05-21 13:46:34 -04:00
Joseph Doherty 53508c79b2 Merge branch 'feature/audit-apicall-payloads': capture API-call payloads
Outbound API audit rows now carry the request arguments and response body
(sync ApiCall + cached immediate-completion path); the emitter previously
hard-coded both summary fields to null.
2026-05-21 10:17:50 -04:00
Joseph Doherty 849a011400 fix(auditlog): capture request/response payloads on outbound API audit rows
The outbound ApiCall emitter hard-coded RequestSummary/ResponseSummary to null,
so audited API calls carried no inputs/outputs — contrary to the Audit Log
payload-capture spec. Thread the call arguments into the sync ApiCall emitter
and the cached immediate-completion path (CachedSubmit / ApiCallCached /
CachedResolve), and stamp the response body from ExternalCallResult.ResponseJson.
The writer's payload filter still applies the size cap + redaction downstream.

The S&F retry-loop cached rows are unchanged — request data is not threaded
through the store-and-forward buffer (same boundary as SourceScript).
2026-05-21 10:17:42 -04:00
Joseph Doherty 405de525ca Merge branch 'feature/audit-channel-single-select': single-select Channel filter
The Audit Log Channel filter becomes a single-select — Kind narrows to the
chosen channel, so multi-channel selection is incoherent. Kind, Status and Site
stay multi-select.
2026-05-21 10:03:08 -04:00
Joseph Doherty 77922abb33 feat(centralui): single-select Channel filter on the Audit Log page
Channel narrows the Kind options to the chosen channel, so filtering by more
than one channel at a time is incoherent. Replace the Channel multi-select
dropdown with a native single-select (matching the Time range control); Kind,
Status and Site stay multi-select. The query filter contract is unchanged —
Channels just carries 0 or 1 value.
2026-05-21 10:02:17 -04:00
Joseph Doherty 5f544bfe1e Merge branch 'feature/audit-actor-identity': populate audit Actor column
Stamp the audit Actor column on outbound rows (calling script identity) and
central-dispatch rows (system identity); the original emission code left it
null on every channel except Inbound API.
2026-05-21 09:56:43 -04:00
Joseph Doherty aaa6df24cf Merge branch 'feature/audit-filter-dropdowns': compact audit filter dropdowns
Replace the four stacked chip-button groups on the Audit Log filter bar with a
reusable MultiSelectDropdown component, collapsing the bar from four full-width
chip blocks to four inline dropdowns in one wrapped filter row.
2026-05-21 09:56:43 -04:00
Joseph Doherty ae7329034f fix(auditlog): populate the Actor column on outbound and central rows
Per the Audit Log Actor-column spec, Actor should carry the calling script
identity on outbound rows (ApiCall, DbWrite, NotifySend) and a system identity
on central-dispatch rows (NotifyDeliver). The original emission code hard-coded
Actor=null at all four sites, so only Inbound API rows (API key name) ever
filled it. Stamp the script identity and 'system' respectively.
2026-05-21 09:50:55 -04:00
Joseph Doherty e36f0bf9c8 feat(centralui): compact multi-select dropdowns for the audit filter bar
Replace the four stacked chip-button groups (Channel, Kind, Status, Site) on
the Audit Log filter bar with a reusable MultiSelectDropdown component, so the
bar collapses from four full-width chip blocks to four inline dropdowns sharing
one wrapped filter row. Bootstrap dropdown + checkbox menu (data-bs-auto-close
=outside); no third-party UI libraries.
2026-05-21 09:36:36 -04:00
Joseph Doherty a3eb659b75 Merge branch 'feature/audit-log-followups': Audit Log #23 deferred follow-ups
Implements the five deferred follow-ups from the Audit Log #23 roadmap:
- Real ClusterClient-based site->central audit push (replaces NoOpSiteStreamAuditClient)
- Consolidated the duplicated AuditEvent/SiteCall DTO mappers
- Site Calls UI page + read-side backend + central->site Retry/Discard relay + Health KPI tiles
- Multi-value AuditLogQueryFilter end-to-end (repository, ManagementService, CLI, Central UI)
- Audit results grid column resize/reorder UX

Full solution build clean; full test suite green including Playwright 60/60.
2026-05-21 09:27:52 -04:00
Joseph Doherty d34f536220 fix(centralui): stabilise Site Calls + Audit grid Playwright E2E
Three Playwright E2E failures, all test-side timing/data bugs (no
feature defects found):

- AuditGridColumnTests.ColumnOrderAndWidths_PersistAcrossReload: read
  sessionStorage synchronously right after Mouse.UpAsync, racing the
  async OnColumnResized/OnColumnReordered JS->.NET->JS save round-trip.
  Now polls (WaitForFunctionAsync) for the storage keys and for the
  reorder re-render to settle; also hardens the flaky ReorderDrag test.

- SiteCallsPageTests.FilterNarrowing_ChannelFilterShrinksGrid: the
  Target-keyword #sc-search @bind committed via the Query click's own
  blur, racing change vs click on the circuit so Search() sometimes
  ran with a stale empty filter. Commit the value with an explicit,
  fully-awaited DispatchEventAsync('change') and use the retrying
  ToHaveCount assertion for the negative row checks.

- SiteCallsPageTests.RetryClickThrough_OnParkedRow: seeded SourceSite
  'plant-a' is not a real cluster site (site-a/b/c), so the relay had
  no ClusterClient route and only resolved on the 10s inner Ask
  timeout - past the 5s toast wait. Seed a live site (site-a) for a
  fast NotParked round-trip and give the toast a 15s wait.

Playwright E2E suite: 60 passed, 0 failed, 0 skipped.
2026-05-21 09:22:50 -04:00
Joseph Doherty 40955bbca6 docs(plan): mark audit-log follow-up tasks complete 2026-05-21 06:41:53 -04:00
Joseph Doherty 7a386a80ce docs(auditlog): mark follow-ups complete in roadmap; refresh stale comments 2026-05-21 06:39:49 -04:00
Joseph Doherty c503df4c4c fix(centralui): stabilize audit grid th nodes with @key; doc grid limitations 2026-05-21 06:33:20 -04:00
Joseph Doherty f1478c5a19 feat(centralui): column resize and reorder for the audit results grid
Adds drag-to-resize and drag-to-reorder column UX to AuditResultsGrid,
with chosen widths + column order persisted in browser sessionStorage.

- wwwroot/js/audit-grid.js: dependency-free helper — pointer-driven
  resize handles, native HTML5 drag-and-drop reorder, and a
  sessionStorage save/load wrapper (mirrors treeview-storage.js).
- AuditResultsGrid: renders a resize handle per <th>, makes headers
  draggable, applies persisted widths via a --audit-col-width custom
  property, and wires reorder into the existing ColumnOrder /
  OrderedColumns() mechanism. JS-invokable OnColumnResized /
  OnColumnReordered persist + re-render. A stored order naming an
  unknown column degrades gracefully (drops unknown keys, appends
  missing columns in default order); widths clamp to a 64px minimum.
- AuditResultsGrid.razor.css: subtle scoped styling for the resize
  handle affordance and the reorder drop-target highlight.
- App.razor references audit-grid.js alongside the other scripts.
- Tests: 6 new bUnit tests for the load/apply/persist logic and
  graceful degradation; a new AuditGridColumnTests Playwright suite
  for the drag UX + reload persistence. Audit page bUnit tests set
  loose JSInterop mode since the grid now calls into audit-grid.js.
2026-05-21 06:27:46 -04:00
Joseph Doherty f64a7aed02 refactor(audit): consolidate query-param parsers; widen CLI export to multi-value 2026-05-21 05:37:06 -04:00
Joseph Doherty 2a76be1f94 feat(audit): multi-value filters across ManagementService, CLI and Central UI 2026-05-21 05:27:17 -04:00
Joseph Doherty 37c7a0e5ac feat(auditlog): multi-value AuditLogQueryFilter dimensions 2026-05-21 05:15:51 -04:00
Joseph Doherty b3b02a8cb6 fix(centralui): apply status/stuck query-string filters on the Site Calls page 2026-05-21 05:08:50 -04:00
Joseph Doherty 44f1ee372a feat(centralui): Site Call KPI tiles on the Health dashboard 2026-05-21 05:04:16 -04:00
Joseph Doherty d73b459057 fix(centralui): single relay toast, paging/skip polish, extra Site Calls tests 2026-05-21 04:59:12 -04:00
Joseph Doherty 7e9d74697b feat(centralui): Site Calls page with Retry/Discard and Audit drill-in 2026-05-21 04:51:14 -04:00
Joseph Doherty 3cf2b4d47e fix(sitecallaudit): correct stale relay docs and clarify ack switch 2026-05-21 04:43:48 -04:00
Joseph Doherty 7816b840c1 feat(sitecallaudit): central→site Retry/Discard relay for parked operations 2026-05-21 04:36:04 -04:00
Joseph Doherty ac1f73cf8a fix(sitecallaudit): push StuckOnly filter into SQL; doc accuracy fixes 2026-05-21 04:24:16 -04:00
Joseph Doherty e3519fdb39 feat(sitecallaudit): query, KPI and detail backend for the Site Calls page 2026-05-21 04:14:49 -04:00
Joseph Doherty 6f0d2ca499 refactor(auditlog): consolidate SiteCall DTO mapper into Communication
Extract the verbatim-duplicated SiteCallOperationalDto -> SiteCall mapper
into a single public SiteCallDtoMapper static class in
ScadaLink.Communication.Grpc, mirroring AuditEventDtoMapper. Replaces three
identical private copies (SiteStreamGrpcServer.MapSiteCallFromDto,
ClusterClientSiteAuditClient.MapSiteCall, and the test-infra
DirectActorSiteStreamAuditClient.MapSiteCallFromDto), removes the now-stale
doc comment that justified the duplication, and drops the using directives
that became unused. Adds SiteCallDtoMapperTests for field-by-field coverage.

Only the FromDto direction is provided: nothing maps SiteCall back onto the
wire, so a ToDto would be dead code.
2026-05-21 04:00:20 -04:00
Joseph Doherty fdd1a4b886 refactor(auditlog): consolidate AuditEvent DTO mappers into Communication 2026-05-21 03:51:51 -04:00
Joseph Doherty 6f59a1b546 fix(auditlog): assert Forwarded state in push integration test; tidy docs and Host wiring 2026-05-21 03:46:40 -04:00
Joseph Doherty de5280d1c7 feat(auditlog): real ClusterClient-based site audit push client 2026-05-21 03:39:17 -04:00
Joseph Doherty 8c78913503 fix(communication): correct audit-ingest timeout-path docs and add timeout test 2026-05-21 03:29:54 -04:00
Joseph Doherty 6d073046c6 feat(communication): route audit ingest commands through CentralCommunicationActor 2026-05-21 03:23:30 -04:00
Joseph Doherty 5fe08eaceb docs(plan): audit-log deferred follow-ups implementation plan 2026-05-21 03:17:59 -04:00
Joseph Doherty 44f7aabe31 Merge branch 'feature/notification-detail-body-recipient': detail modal shows body + recipients
The /notifications/report detail modal showed only NotificationSummary
fields. Added a NotificationDetailRequest query (mirrors the existing
outbox query plumbing — Commons contract + NotificationOutboxActor
handler + CommunicationService method) that fetches the full Notification
entity. The modal now fetches on open and renders the message Body
(preformatted plain text, never MarkupString) and resolved recipients
(ResolvedTargets, with a list-name fallback note when not yet resolved).
6 new tests.
2026-05-21 02:52:26 -04:00
Joseph Doherty babf5b99e7 feat(ui): notification detail modal shows message body + recipients 2026-05-21 02:49:17 -04:00
Joseph Doherty 194cae2fbf feat(notif): NotificationDetailRequest query for full notification detail 2026-05-21 02:47:43 -04:00
Joseph Doherty 8fd0cf355b Merge branch 'feature/notification-report-detail-modal': row double-click detail modal
Double-clicking a row on /notifications/report opens a Bootstrap modal
showing the notification's full detail (untruncated ID, full LastError,
SourceInstanceId, exact timestamps) — fields the grid truncates or omits.
Parked notifications also get Retry/Discard buttons in the modal footer.
Inline no-JS modal, in-memory NotificationSummary, no extra query.
2026-05-21 02:40:07 -04:00
Joseph Doherty ef5cf76026 feat(ui): notification report row double-click opens detail modal 2026-05-21 02:39:41 -04:00
Joseph Doherty 80076a3951 Merge branch 'chore/dev-cluster-dispatch-tuning': raise dev-cluster notification dispatch throughput 2026-05-21 02:35:22 -04:00
Joseph Doherty 1c9b2445ad chore(dev-cluster): raise NotificationOutbox dispatch throughput
Both central nodes ran on the NotificationOutboxOptions code defaults
(100 / 10s = 600/min) because the mounted per-node appsettings.Central.json
had no ScadaLink:NotificationOutbox section. Add the section with
DispatchBatchSize 1000 + DispatchInterval 5s — measured ~6,000/min after
restart (sweep duration becomes the binding constraint, which is fine:
the no-overlap guard self-regulates). Dev-cluster tuning only.
2026-05-21 02:35:22 -04:00
Joseph Doherty 163446948d Merge branch 'feature/smtp-config-tls-credentials': make SMTP TlsMode + Credentials configurable
Closes the gap found while debugging notification delivery: SmtpConfiguration
has TlsMode + Credentials fields, but no non-SQL path could set them.

- UpdateSmtpConfigCommand carries optional TlsMode + Credentials (nullable,
  preserve-if-null in HandleUpdateSmtpConfig — non-breaking for existing
  5-arg callers).
- CLI 'notification smtp update' gains optional --tls-mode (validated
  None/StartTLS/SSL) and --credentials.
- Central UI SMTP form gains a TLS Mode select (None/StartTLS/SSL) —
  previously the form had no TlsMode field at all.
- docs/test_infra/test_infra_smtp.md: replaced the invalid AuthMode:None
  example with a working Basic config (TlsMode None, dummy credentials);
  corrected the prose (delivery requires Basic/OAuth2, no anonymous mode)
  and noted the scadalink-smtp container hostname for in-cluster use.

4 commits, 13 new tests. Full solution green.
2026-05-21 02:16:23 -04:00
Joseph Doherty e58e038db9 docs(test-infra): correct SMTP example — Basic auth, TlsMode None, container hostname
The appsettings example used AuthMode 'None', which the delivery code
(MailKitSmtpClientWrapper) rejects — only Basic and OAuth2 are valid.
Switch to a working Basic config with Credentials and TlsMode None, and
document that Server must be the container name scadalink-smtp when the
Notification Service runs inside the docker cluster.
2026-05-21 02:13:19 -04:00
Joseph Doherty c66ef71017 feat(ui): SMTP config form TlsMode field
Add a TlsMode read-only row and a None/StartTLS/SSL select to the SMTP
Configuration page edit form. New configs default to None; edits load
and persist the chosen mode through the repository.
2026-05-21 02:13:02 -04:00
Joseph Doherty 399b4aac92 feat(cli): notification smtp update --tls-mode / --credentials options
Expose the two previously-unreachable SmtpConfiguration fields on the
CLI. Both flags are optional — omitting them sends null so the server
preserves the existing value. --tls-mode is constrained to the canonical
{None, StartTLS, SSL} set via AcceptOnlyFromAmong for fast-fail.
2026-05-21 02:11:51 -04:00
Joseph Doherty ec92d55ebf feat(smtp): UpdateSmtpConfigCommand carries TlsMode + Credentials
Add two optional nullable fields (TlsMode, Credentials) to the
UpdateSmtpConfigCommand record. The handler applies preserve-if-null
semantics: an update that omits a field leaves the existing value
intact, so existing 5-arg callers remain non-breaking.
2026-05-21 02:11:03 -04:00
173 changed files with 16214 additions and 842 deletions
+1
View File
@@ -132,6 +132,7 @@ This project contains design documentation for a distributed SCADA system built
- Layered design — append-only `AuditLog` (#23) sits alongside operational `Notifications` (#21) and `SiteCalls` (#22), not replacing them. - Layered design — append-only `AuditLog` (#23) sits alongside operational `Notifications` (#21) and `SiteCalls` (#22), not replacing them.
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded. - Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`). - One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost. - Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction. - Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. - Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in.
@@ -53,6 +53,10 @@
"AuthMode": "None", "AuthMode": "None",
"FromAddress": "scada-notifications@company.com" "FromAddress": "scada-notifications@company.com"
}, },
"NotificationOutbox": {
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"Logging": { "Logging": {
"MinimumLevel": "Information" "MinimumLevel": "Information"
} }
@@ -53,6 +53,10 @@
"AuthMode": "None", "AuthMode": "None",
"FromAddress": "scada-notifications@company.com" "FromAddress": "scada-notifications@company.com"
}, },
"NotificationOutbox": {
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"Logging": { "Logging": {
"MinimumLevel": "Information" "MinimumLevel": "Information"
} }
@@ -11,12 +11,17 @@
> >
> **Deferred to v1.x (out of scope, intentionally not implemented):** hash-chain tamper > **Deferred to v1.x (out of scope, intentionally not implemented):** hash-chain tamper
> evidence (`audit verify-chain` ships as a no-op stub), Parquet export (`format=parquet` > evidence (`audit verify-chain` ships as a no-op stub), Parquet export (`format=parquet`
> returns HTTP 501), per-channel retention overrides. **Deferred follow-ups noted during > returns HTTP 501), per-channel retention overrides. **Follow-ups noted during
> implementation:** the real site→central gRPC push client (M6 wired the pull RPC + a mockable > implementation — now complete:** the five follow-ups deferred above (the real
> push seam; `NoOpSiteStreamAuditClient` remains the production binding); consolidation of the > site→central push client; consolidation of the 4 DTO mapper copies; the Site Calls UI
> 4 DTO mapper copies; Site Calls UI page + its Audit drill-in; multi-value filter dimensions > page + its Audit drill-in; multi-value filter dimensions; audit-results-grid drag
> (`AuditLogQueryFilter` is single-value per dimension, so UI chips / CLI flags collapse to the > resize/reorder UX) were all implemented on the `feature/audit-log-followups` branch
> first value); audit-results-grid drag resize/reorder UX. > per `docs/plans/2026-05-21-audit-log-followups.md`. The site→central transport shipped
> as a **ClusterClient-based push** (`ClusterClientSiteAuditClient`, reusing the same
> ClusterClient command/control transport notifications use) rather than the gRPC push
> originally sketched here — `ClusterClientSiteAuditClient` is now the production binding
> for site roles, with `NoOpSiteStreamAuditClient` retained only for central/test
> composition roots; and `AuditLogQueryFilter` is now multi-value per dimension.
> >
> **For Claude:** REQUIRED SUB-SKILL FLOW per milestone: `brainstorming` → `writing-plans` → `subagent-driven-development`. Use `docs/requirements/Component-AuditLog.md` + `alog.md` as the spec; this document is the roadmap that sequences milestones and locks acceptance criteria for each. **M1 carries full TDD-level task detail; M2M8 are milestone-shape detail and will be expanded into bite-sized plans by their own writing-plans pass when their turn comes.** > **For Claude:** REQUIRED SUB-SKILL FLOW per milestone: `brainstorming` → `writing-plans` → `subagent-driven-development`. Use `docs/requirements/Component-AuditLog.md` + `alog.md` as the spec; this document is the roadmap that sequences milestones and locks acceptance criteria for each. **M1 carries full TDD-level task detail; M2M8 are milestone-shape detail and will be expanded into bite-sized plans by their own writing-plans pass when their turn comes.**
@@ -0,0 +1,115 @@
# Audit Log — ExecutionId Universal Correlation (Design)
**Date:** 2026-05-21
**Status:** Validated — ready for implementation planning.
## Problem
The audit `CorrelationId` column is overloaded with three incompatible meanings —
`TrackedOperationId` for cached calls, `NotificationId` for notifications, the
script-execution id for sync calls (added 2026-05-21), and request-local ids for
inbound. It is `NULL` for sync one-shot calls. There is no single value that ties
together *everything one script run (or inbound request) did*: a run that makes a
sync API call, a cached call and a notification produces three unrelated
correlation ids, and nothing links the cached call's lifecycle rows back to the
run that launched them.
A single `CorrelationId` column cannot serve both scopes — the **operation
lifecycle** (a cached call's `Submit→Attempted→Resolve`; a notification's
`Send→Deliver`, which the Site Calls / Notifications "View audit history"
drill-ins depend on) and the **execution trace** (all operations of one run).
## Decision
Add a dedicated, nullable **`ExecutionId`** column to the audit row. It identifies
the originating **script execution** or **inbound API request**. Every audit row
that execution produces carries the same `ExecutionId`. `CorrelationId` is left
exactly as it is — it keeps the per-operation lifecycle meaning, so the existing
operation drill-ins are unaffected.
Result: `WHERE ExecutionId = X` returns every audit row of one run — sync
`ApiCall`/`DbWrite`, the whole cached-call lifecycle, `NotifySend`,
`NotifyDeliver`, and the inbound row — across both the site and central tables.
`ScriptRuntimeContext` already holds a per-execution id (`_auditCorrelationId`,
added 2026-05-21). That id becomes the `ExecutionId`; this work stamps it into the
new column from every emitter and threads it to the two paths where the script
context is not in scope.
### Considered and rejected
- **Overload `CorrelationId`** with the execution id everywhere — breaks the
cached-call / notification "View audit history" drill-ins (they filter
`CorrelationId` by `TrackedOperationId` / `NotificationId`), or forces them to
show the whole run instead of the one operation.
- **Stash the execution id in `Extra` JSON** — no schema change, but `Extra` is
unindexed; filtering an audit table of this volume by it is unworkable.
## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`)
| Where | Change |
|---|---|
| `ScadaLink.Commons` | `AuditEvent` record (and the site-local variant) gains `Guid? ExecutionId`. |
| Central MS SQL `AuditLog` | new `ExecutionId uniqueidentifier NULL` column + index `IX_AuditLog_Execution (ExecutionId)`. EF migration — additive nullable column is a metadata-only `ALTER`, fast even on the monthly-partitioned table. |
| Site SQLite `auditlog.db` `AuditLog` | new `ExecutionId TEXT NULL` column (`SqliteAuditWriter` schema + `MapRow`). |
| gRPC `AuditEventDto` (`sitestream.proto`) | additive `execution_id` field; `AuditEventDtoMapper` maps it both directions. |
| Central MS SQL `Notifications` | new `OriginExecutionId uniqueidentifier NULL` column — carries the originating run's id so the dispatcher can echo it onto `NotifyDeliver` audit rows. EF migration. |
`SiteCalls` needs no new column — the cached telemetry packet already carries the
audit half, which now has `ExecutionId` directly.
## Emitter coverage — every audit row carries `ExecutionId`
| Emitter | `ExecutionId` source |
|---|---|
| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext` execution id (in scope today) |
| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext` execution id |
| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the store-and-forward buffered message → `CachedCallAttemptContext` → the bridge. This same threading also fixes the pre-existing `SourceScript = NULL` gap on those rows (identical boundary). |
| `NotifySend` (site, script-side) | `ScriptRuntimeContext` execution id |
| `NotifyDeliver` (central dispatch) | `Notifications.OriginExecutionId` — the id rides on `NotificationSubmit`, is persisted on the `Notifications` row, and the dispatcher stamps it on every `NotifyDeliver` row |
| Inbound `InboundRequest` / `InboundAuthFailure` | request id minted once in `AuditWriteMiddleware` |
## Data flow
- **Site script run**`ScriptRuntimeContext` generates the execution id (or is
given one); every emitter it owns stamps `ExecutionId`.
- **Buffered cached call** — the execution id rides on the S&F buffered message;
the retry loop reconstructs it into `CachedCallAttemptContext`;
`CachedCallLifecycleBridge` stamps it on the retry-loop audit rows.
- **Notification** — the `NotifySend` row stamps it site-side; the id travels on
`NotificationSubmit`, is stored as `Notifications.OriginExecutionId`, and the
dispatcher stamps every `NotifyDeliver` row it emits.
- **Inbound API request**`AuditWriteMiddleware` mints a request id and stamps
the inbound audit row.
## UI / CLI surface
- **Central UI Audit Log page**`ExecutionId` added as a results-grid column
(the grid already supports resize/reorder); an `ExecutionId` paste-filter in
the filter bar; the page accepts `?executionId=<guid>`; a row drill-in
"View this execution" → `/audit/log?executionId=<guid>`.
- **CLI**`scadalink audit query --execution-id <guid>`.
- **ManagementService**`/api/audit/query` and the export endpoint accept an
`executionId` filter parameter.
## Compatibility
- Two additive nullable columns; additive proto field; additive message-contract
fields — all version-compatible. No data backfill; historical rows keep
`ExecutionId = NULL`.
- `CorrelationId` semantics unchanged — every existing drill-in keeps working.
## Testing
- Repository: query-by-`ExecutionId`; migration smoke test.
- Emitter unit tests: each emitter stamps `ExecutionId`; the cached-call lifecycle
rows from one run share it; `NotifyDeliver` echoes `Notifications.OriginExecutionId`.
- Integration: a script run that does a sync call + a cached call + a notification
→ all resulting audit rows share one `ExecutionId` end-to-end.
- Central UI: bUnit (grid column, filter, drill-in) + Playwright.
## Out of scope
- Bridging the inbound request id into the routed site script's execution
(cross-cluster threading) — a separate future change.
- Backfilling `ExecutionId` on historical audit rows.
+155
View File
@@ -0,0 +1,155 @@
# Audit Log ExecutionId — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review).
**Goal:** Add a dedicated `ExecutionId` column to the Audit Log — one universal correlation value, stamped on every audit row, identifying the originating script execution or inbound request.
**Architecture:** Additive nullable `ExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). Every emitter stamps it; the `ScriptRuntimeContext` per-execution id is the source for site script runs, threaded through the S&F buffer for retry-loop cached rows and through `NotificationSubmit``Notifications.OriginExecutionId` for central `NotifyDeliver` rows. `CorrelationId` is left as the per-operation lifecycle id (and reverts to `null` for sync one-shot calls). Validated design: `docs/plans/2026-05-21-audit-executionid-design.md`.
**Tech Stack:** .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright.
**Ground rules (every task):** branch is `feature/audit-executionid` (already created) — never commit to `main`. Edit in place; never touch `infra/*`; `docker/*` only if a task says so (none do). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. TDD; full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on). Additive contract evolution. Do not push.
---
## Task 0: Prep — verify branch + baseline
**Files:** none.
**Steps:** confirm `git branch --show-current` is `feature/audit-executionid`; `dotnet build ScadaLink.slnx` succeeds.
**Acceptance:** on the branch, solution builds clean.
---
## Task 1: Foundation — `AuditEvent.ExecutionId`, central `AuditLog` column, repository query
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ExecutionId`.
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ExecutionId` filter dimension (single-value, like `CorrelationId`).
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_Execution (ExecutionId)`.
- Create: a new EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/``AddAuditLogExecutionId``ExecutionId uniqueidentifier NULL` + the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existing `AddNotificationsTable` migration style.
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs``QueryAsync` translates `filter.ExecutionId` to `e.ExecutionId == value` (mirror the `CorrelationId` clause). Keyset paging untouched.
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs``QueryAsync_FilterByExecutionId`; migration smoke if the suite has that pattern.
**Approach:** purely additive. `ExecutionId` is `Guid?` everywhere. Generate the migration with `dotnet ef migrations add` against the ConfigurationDatabase project (or hand-write mirroring an existing one — match how the repo does migrations).
**Commit:** `feat(auditlog): ExecutionId column on AuditEvent + central AuditLog`
---
## Task 2: Foundation — site SQLite + gRPC DTO
**Files:**
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table DDL; the insert command binds it; `MapRow` reads it back. (Site SQLite is created fresh by the writer — an additive column in the `CREATE TABLE` is enough; if the writer has any migration/ALTER path, extend it.)
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs``ToDto`/`FromDto` map `ExecutionId``execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `CorrelationId` handling).
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` (column present + round-trips); `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` (ExecutionId round-trip incl. null).
**Commit:** `feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto`
---
## Task 3: Site script-side emitters stamp `ExecutionId`
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ExecutionId` = the context's per-execution id. Revert the interim "execution id in `CorrelationId` for sync rows" change so `CorrelationId` is purely per-operation again.
**Files:**
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
- Rename the field `_auditCorrelationId``_executionId` (and the ctor param `auditCorrelationId``executionId`) for clarity; update XML docs. Thread it to the helpers as today.
- Sync `ApiCall` (`BuildCallAuditEvent`): set `ExecutionId = _executionId`; set `CorrelationId = null` (revert — sync one-shot calls have no operation lifecycle).
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve`): set `ExecutionId = _executionId`; `CorrelationId` stays `trackedId.Value`.
- `NotifySend` (`Notify.Send` emission): set `ExecutionId = _executionId`; `CorrelationId` stays the `NotificationId`.
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_executionId` (rename from the audit-correlation param); sync `DbWrite` event sets `ExecutionId = _executionId` and `CorrelationId = null`. Cached DB write rows: `ExecutionId` set, `CorrelationId` stays `trackedId`.
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, and `ExecutionCorrelationContextTests.cs` — assert `ExecutionId` is the context's id on every row; assert sync rows now have `CorrelationId == null`; assert cached/notification rows keep their `CorrelationId`.
**Commit:** `feat(auditlog): site script-side emitters stamp ExecutionId`
---
## Task 4: Cached S&F retry-loop rows carry `ExecutionId`
**What:** Thread the execution id through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry `ExecutionId`. This same threading fixes the pre-existing `SourceScript = null` gap on those rows (identical boundary).
**Files:**
- Modify: the S&F buffered cached-call message / `StoreAndForwardMessage` (or the cached-call payload) in `src/ScadaLink.StoreAndForward/` — carry the originating execution id (and source script) alongside the call.
- Modify: `CachedCallAttemptContext` (find it — `src/ScadaLink.AuditLog/Site/Telemetry/` or StoreAndForward) — add an `ExecutionId` (and `SourceScript`) field.
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ExecutionId` from the context (and `SourceScript`, replacing the `SourceScript = null` line).
- Modify the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext`) so the execution id is written into the buffered message.
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ExecutionId`.
**Note for implementer:** this is the deepest task — the threading touches StoreAndForward. If the buffered message can't cleanly carry the id, STOP and report before guessing.
**Commit:** `feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows`
---
## Task 5: Central `NotifyDeliver` rows carry `ExecutionId`
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginExecutionId`.
- Modify: `src/ScadaLink.Commons/Messages/Notification/``NotificationSubmit` carries `Guid? OriginExecutionId` (additive).
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config + a new migration `AddNotificationOriginExecutionId` (`Notifications.OriginExecutionId uniqueidentifier NULL`).
- Modify: the site `NotifySend` forward path — the execution id (already on the `NotifySend` audit row from Task 3) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder).
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ExecutionId = notification.OriginExecutionId`.
- Test: `tests/ScadaLink.NotificationOutbox.Tests/``NotifyDeliver` rows echo `OriginExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ExecutionId`
---
## Task 6: Inbound rows carry `ExecutionId`
**Files:**
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs``EmitInboundAudit` sets `ExecutionId` to the request id (it already mints a `Guid.NewGuid()` for the inbound `CorrelationId` per the 2026-05-21 change; reuse that one id for `ExecutionId` — and reconsider whether the inbound row's `CorrelationId` should now be `null` to keep `CorrelationId` purely per-operation; align with the Task 3 decision: inbound is a one-shot from the audit row's perspective → `CorrelationId = null`, `ExecutionId = <request id>`).
- Test: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — inbound row carries a non-null `ExecutionId`; distinct per request.
**Commit:** `feat(auditlog): inbound audit rows carry ExecutionId`
---
## Task 7: Central UI — ExecutionId column, filter, drill-in
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ExecutionId` to the column set (the grid already supports resize/reorder + a `ColumnOrder`); render it (short form / monospace).
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — an `ExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ExecutionId`.
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs``ApplyQueryStringFilters` accepts `?executionId=<guid>`; `BuildExportUrl` emits it.
- Add a "View this execution" drill-in — a row/drilldown action linking `/audit/log?executionId=<guid>`. Mirror the existing `?correlationId=` drill-in.
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in filters the grid).
Use the `frontend-design` skill for the column/filter styling.
**Commit:** `feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page`
---
## Task 8: CLI + ManagementService — ExecutionId filter
**Files:**
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs``audit query --execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `executionId`.
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `executionId` query param into `AuditLogQueryFilter.ExecutionId` (lax-parse — unparseable dropped).
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
**Commit:** `feat(audit): ExecutionId filter in the CLI and ManagementService`
---
## Task 9: End-to-end integration test + docs
**Files:**
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ExecutionIdCorrelationTests.cs` — boot a site+central pair; run a script that does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`; assert every resulting audit row (site + central) shares one `ExecutionId`.
- Modify: `docs/requirements/Component-AuditLog.md` — add `ExecutionId` to the schema table and a sentence on its meaning vs `CorrelationId`. (Do NOT modify `alog.md` — it is the locked v1 spec.)
- Modify: `CLAUDE.md` — one line under the Centralized Audit Log decisions noting `ExecutionId` as the universal per-run correlation value.
**Commit:** `test(auditlog): end-to-end ExecutionId correlation + docs`
---
## Final review
Dispatch a final cross-cutting review of the whole branch; full `dotnet build` + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
## Dependency summary
0 blocks all. 2 blockedBy 1. 3 blockedBy 2. 4 blockedBy 3. 5 blockedBy 2. 6 blockedBy 2. 7 blockedBy 1. 8 blockedBy 1. 9 blockedBy 3,4,5,6,7,8. Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → final review.
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
"tasks": [
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
],
"lastUpdated": "2026-05-21T00:00:00Z"
}
@@ -0,0 +1,249 @@
# Audit Log #23 — Deferred Follow-ups Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (bundled cadence — one implementer + one review pass per task).
**Goal:** Close the five deferred implementation follow-ups from the Audit Log #23 roadmap so site audit events actually reach central, the audit/SiteCall surfaces are complete, and known tech debt is paid down.
**Architecture:** Five independent-ish workstreams against the existing ScadaLink codebase. The headline change: site→central audit forwarding moves from the production `NoOpSiteStreamAuditClient` stub to a real **ClusterClient-based push** — the same transport notifications already use (`SiteCommunicationActor``ClusterClient.Send("/user/central-communication", …)``CentralCommunicationActor`), avoiding a new central-hosted gRPC server. The remaining four follow-ups are scoped tech-debt / UI / contract changes.
**Tech Stack:** .NET 10, Akka.NET (ClusterClient, ClusterClientReceptionist, cluster singletons, TestKit), EF Core 10 (MS SQL + SQLite providers), Blazor Server + Bootstrap CSS (no third-party UI libs), System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute, Playwright.
**Spec sources:** `alog.md`, `docs/requirements/Component-AuditLog.md`, `docs/requirements/Component-SiteCallAudit.md`, `docs/plans/2026-05-20-audit-log-code-roadmap.md` (header lines 1419 enumerate the deferred items).
**Ground rules (carry into every task):**
- Branch off `main` before any code change; never commit on `main`.
- Edit in place. Never touch `infra/*`. The `docker/*` cluster config is touched only if a task explicitly says so (none here do).
- Stage with explicit `git add <path>` — never `git add .`, never `git commit -am`.
- TDD: failing test → minimal code → green → commit. Full solution stays green (`dotnet build ScadaLink.slnx`, `dotnet test ScadaLink.slnx`).
- Additive message-contract evolution where possible; where a contract shape must change (Task 8), update every call site in the same task.
- Do not push to origin — the user authorizes pushes separately.
---
## Task 0: Prep — feature branch
**Files:** none (git only).
**Step 1:** From a clean `main`, create the working branch:
```bash
git checkout main && git status --porcelain # expect clean
git checkout -b feature/audit-log-followups
```
**Step 2:** Confirm baseline green:
```bash
dotnet build ScadaLink.slnx
```
Expected: build succeeds. (A full `dotnet test` baseline is optional but recommended.)
**Acceptance:** on branch `feature/audit-log-followups`, solution builds.
---
## Task 1: Audit push — central ingest routing over ClusterClient
**What:** Make the receptionist-registered `CentralCommunicationActor` accept `IngestAuditEventsCommand` (and `IngestCachedTelemetryCommand`) from a site ClusterClient, forward to the `AuditLogIngestActor` cluster-singleton proxy, and pipe the reply back. Mirror the existing `NotificationSubmit` / `RegisterNotificationOutbox` pattern exactly.
**Files:**
- Modify: `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs` — add `Receive<IngestAuditEventsCommand>` + `Receive<IngestCachedTelemetryCommand>` handlers; add a `RegisterAuditIngest` registration message handler holding the `AuditLogIngestActor` proxy `IActorRef` (mirror `RegisterNotificationOutbox` at line ~120 / `HandleNotificationSubmit` at line ~130).
- Create: `src/ScadaLink.Commons/Messages/Audit/RegisterAuditIngest.cs``public sealed record RegisterAuditIngest(IActorRef AuditIngestActor);` (mirror `RegisterNotificationOutbox`).
- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — after the central `AuditLogIngestActor` singleton + proxy are created (~lines 355379), `Tell` the `RegisterAuditIngest` to the `CentralCommunicationActor` (mirror how the Notification Outbox proxy is registered).
- Test: `tests/ScadaLink.Communication.Tests/Actors/CentralCommunicationActorAuditTests.cs` (new).
**Approach:**
- Handler `Ask`s the registered audit-ingest proxy and `PipeTo`s the `IngestAuditEventsReply` back to the original `Sender` (the ClusterClient round-trips it to the site). Use the existing audit-ingest Ask-timeout convention (30s — see `SiteStreamGrpcServer` `AuditIngestAskTimeout`); add a bound option if no constant is reachable.
- If no audit-ingest proxy is registered yet (startup race), reply with an empty `IngestAuditEventsReply([])` — the site keeps the rows `Pending` and retries, exactly as the gRPC handler does today.
- `IngestCachedTelemetryCommand` is routed the same way (its reply type is the same `IngestAuditEventsReply` per `AuditLogIngestActor`).
**Tests (TestKit + NSubstitute):**
1. `IngestAuditEventsCommand` with an audit-ingest probe registered → probe receives the command, actor replies the probe's `IngestAuditEventsReply` to the sender.
2. `IngestAuditEventsCommand` with no audit-ingest registered → sender gets `IngestAuditEventsReply` with empty `AcceptedEventIds`.
3. `IngestCachedTelemetryCommand` routes to the same proxy.
**Steps:** write failing tests → run (fail) → implement record + handlers + Host registration → run (pass) → `dotnet build ScadaLink.slnx` → commit.
**Commit:** `feat(communication): route audit ingest commands through CentralCommunicationActor`
---
## Task 2: Audit push — real site client, Host wiring, integration test
**What:** Replace `NoOpSiteStreamAuditClient` (production binding) with a real `ISiteStreamAuditClient` that pushes over ClusterClient via the site's `SiteCommunicationActor`. After this task the site `auditlog.db` `Pending` backlog drains to central.
**Files:**
- Create: `src/ScadaLink.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs` — implements `ISiteStreamAuditClient`; ctor takes the `SiteCommunicationActor` `IActorRef` + an Ask timeout.
- Modify: `src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs` — ensure `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` are forwarded over `ClusterClient.Send("/user/central-communication", …)` with the reply routed back to the Ask (mirror the `NotificationSubmit` forward at lines ~190/214/224).
- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — in the site telemetry wiring (~lines 648681), construct `ClusterClientSiteAuditClient` with the `SiteCommunicationActor` ref and pass it to `SiteAuditTelemetryActor` instead of the DI-resolved `NoOpSiteStreamAuditClient`.
- Modify: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs` (line ~124129) — keep `NoOpSiteStreamAuditClient` as the DI default (it remains correct for central/test composition roots that have no `SiteCommunicationActor`); update the stale comment that says "M6's reconciliation work brings the real implementation".
- Test: `tests/ScadaLink.AuditLog.Tests/Site/Telemetry/ClusterClientSiteAuditClientTests.cs` (new); extend `tests/ScadaLink.IntegrationTests/AuditLog/` with a ClusterClient-push end-to-end test.
**Approach:**
- `IngestAuditEventsAsync(AuditEventBatch, ct)` maps the batch to `IngestAuditEventsCommand(IReadOnlyList<AuditEvent>)`, `Ask`s the `SiteCommunicationActor` for `IngestAuditEventsReply`, maps the reply's `AcceptedEventIds` back into the `IngestAck` the `SiteAuditTelemetryActor` expects.
- An Ask timeout / failure must **throw**`SiteAuditTelemetryActor`'s drain loop already treats a thrown exception as transient (rows stay `Pending`, retried next tick). Keep that contract.
- `IngestCachedTelemetryAsync` does the same with `IngestCachedTelemetryCommand`. (`CachedCallTelemetryForwarder` already resolves `ISiteStreamAuditClient` — no change there.)
- `AuditEvent` already crosses the wire as the `NotificationSubmit` records do; confirm the Akka serializer handles `IReadOnlyList<AuditEvent>` (notification messages prove the pattern).
**Tests:**
1. `IngestAuditEventsAsync` → batch becomes one `IngestAuditEventsCommand`; mocked actor reply's accepted ids map onto `IngestAck`.
2. Partial ack (3 of 5 ids) → `IngestAck` lists only the 3.
3. Ask timeout → method throws (drain loop keeps rows `Pending`).
4. Integration: boot a site+central pair via the IntegrationTests harness, write an audit event on the site hot-path, assert a central `AuditLog` row appears within ~10s and the site row flips to `Forwarded`.
**Commit:** `feat(auditlog): real ClusterClient-based site audit push client`
---
## Task 3: Consolidate the duplicated audit DTO mappers
**What:** Collapse the 4 near-duplicate `AuditEvent``AuditEventDto` mapping copies into one canonical mapper. The project-reference cycle (`AuditLog → Communication`, never the reverse) is resolved by hosting the canonical mapper **in `ScadaLink.Communication`** — it owns the generated `AuditEventDto` and references `Commons` for `AuditEvent`, and `AuditLog` already references `Communication`.
**Files:**
- Create: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs``public static class` with `ToDto(AuditEvent) → AuditEventDto` and `FromDto(AuditEventDto) → AuditEvent` (lift the canonical logic from `AuditLog/Telemetry/AuditEventMapper.cs`).
- Modify: `src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs` — replace the inlined `IngestAuditEvents` loop (~lines 265295), `AuditEventToDto` (~490517) and `MapAuditEventFromDto` (~537561) with calls to `AuditEventDtoMapper`.
- Delete: `src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs`; update its callers in `ScadaLink.AuditLog` to use `Communication`'s `AuditEventDtoMapper`.
- Leave untouched: `SqliteAuditWriter.MapRow` (SQLite `DataReader``AuditEvent`, not a DTO mapper — different source type) and `MapSiteCallFromDto` (SiteCall, not audit). Note this in the commit body.
- Test: move/merge `tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs` into `tests/ScadaLink.Communication.Tests/Grpc/AuditEventDtoMapperTests.cs`; keep round-trip coverage (`FromDto(ToDto(x)) == x`).
**Approach:** Pure refactor — no behaviour change. Verify field-by-field parity against all 3 inlined copies before deleting them (null handling, enum parsing, `Int32Value`/`Timestamp` wrapping).
**Steps:** create mapper + tests → run → swap call sites → delete old copies → `dotnet build` + `dotnet test ScadaLink.slnx` (all green, no behaviour drift) → commit.
**Commit:** `refactor(auditlog): consolidate AuditEvent DTO mappers into Communication`
---
## Task 4: Site Call Audit — query / KPI / detail backend
**What:** Build the missing read-side backend for the Site Calls UI: Commons message contracts, `SiteCallAuditActor` query/KPI/detail handlers, and `CommunicationService` methods. Mirror `NotificationOutboxQueries.cs` + the Notification Outbox actor/service shape. Spec: `Component-SiteCallAudit.md` §KPIs and §queryable list.
**Files:**
- Create: `src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs` — records mirroring `NotificationOutboxQueries.cs`:
- `SiteCallQueryRequest` (CorrelationId, status/site/kind/target filters, date range, page cursor fields, PageSize)
- `SiteCallSummary` (TrackedOperationId, SourceSite, Kind, TargetSummary, Status, RetryCount, LastError, provenance, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc)
- `SiteCallQueryResponse` (CorrelationId, Success, ErrorMessage, IReadOnlyList<SiteCallSummary>, next-cursor fields)
- `SiteCallKpiRequest` / `SiteCallKpiResponse` (BufferedCount, ParkedCount, FailedLastInterval, DeliveredLastInterval, OldestPendingAge, StuckCount — mirror the Notification Outbox KPI shape; also a per-site variant)
- `SiteCallDetailRequest` / `SiteCallDetailResponse` / `SiteCallDetail` (full row incl. LastError, all timestamps).
- Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` — add `ReceiveAsync` handlers for the query / KPI / detail requests; query handler calls `ISiteCallAuditRepository.QueryAsync` (keyset paging on `(CreatedAtUtc DESC, TrackedOperationId DESC)`); KPI handler computes point-in-time counts from the `SiteCalls` table (stuck = `Pending`/`Retrying` older than the configurable threshold, default 10 min). Use the per-message DI scope pattern already in the actor.
- Add repo support if needed: `src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs` may need a KPI-count method + a detail `GetAsync` (a `GetAsync(TrackedOperationId)` already exists).
- Modify: `src/ScadaLink.Communication/CommunicationService.cs` — add `QuerySiteCallsAsync`, `GetSiteCallKpisAsync`, `GetPerSiteSiteCallKpisAsync`, `GetSiteCallDetailAsync` (mirror `QueryNotificationOutboxAsync` etc.: `Ask` the `SiteCallAuditActor` proxy with `_options.QueryTimeout`).
- Test: `tests/ScadaLink.SiteCallAudit.Tests/` (actor handlers), `tests/ScadaLink.Commons.Tests/` (contract shape), `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs` (extend for KPI counts).
**Commit:** `feat(sitecallaudit): query, KPI and detail backend for the Site Calls page`
---
## Task 5: Site Call Audit — Retry/Discard relay to owning site
**What:** Central UI Retry/Discard on a parked Site Call must relay `RetryParkedOperation` / `DiscardParkedOperation` to the **owning site** (sites are the source of truth — central never mutates the `SiteCalls` row directly; the corrected row arrives back via telemetry). Spec: `Component-SiteCallAudit.md` §actions-on-parked-rows.
**Files:**
- Create: `src/ScadaLink.Commons/Messages/Audit/SiteCallRelayMessages.cs``RetryParkedOperationRequest`/`Response`, `DiscardParkedOperationRequest`/`Response` (carry `TrackedOperationId`, `SourceSite`, `CorrelationId`; response carries Success + a "site unreachable" error case).
- Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` (or a small relay collaborator) — on a relay request, look up the owning site and forward `RetryParkedOperation`/`DiscardParkedOperation` to that site over the central→site ClusterClient (the central side already maintains one ClusterClient per site; reuse the `CentralCommunicationActor` site-addressing path). On no/late reply → respond "site unreachable".
- Modify: `src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs` — receive `RetryParkedOperation`/`DiscardParkedOperation` and hand to the site operation-tracking subsystem.
- Modify the site operation-tracking owner (S&F operation-tracking store / `ParkedMessageHandlerActor` in `src/ScadaLink.StoreAndForward/`) — Retry resets a parked tracked operation to `Pending` for the retry loop; Discard marks it `Discarded`. Reuse the parked-message handling that already backs notification Retry/Discard.
- Modify: `src/ScadaLink.Communication/CommunicationService.cs` — add `RetrySiteCallAsync` / `DiscardSiteCallAsync`.
- Test: `tests/ScadaLink.SiteCallAudit.Tests/` (relay routing + unreachable path), `tests/ScadaLink.StoreAndForward.Tests/` (site-side parked op reset/discard), `tests/ScadaLink.Communication.Tests/`.
**Note for implementer:** this is the meatiest backend task — the central→site relay direction and the site-side parked-operation mutation are both required. If the site operation-tracking Retry/Discard primitive already exists for cached calls, reuse it; only add the message plumbing.
**Commit:** `feat(sitecallaudit): central→site Retry/Discard relay for parked operations`
---
## Task 6: Site Calls UI page + nav + Audit drill-in
**What:** Build the Central UI Site Calls page — a near-mirror of `NotificationReport.razor`. Spec: `Component-SiteCallAudit.md`.
**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor` (+ `.razor.cs`) — route `@page "/site-calls/report"`, `RequireDeployment` (or `OperationalAudit`) auth to match the Notifications report gating. Structure (per the form-layout memory: header, filter card, results table, paging, modal):
- Filter card: Status, Kind, Source site, Target keyword, date range, "Stuck only" checkbox, Clear/Query.
- Results table columns: TrackedOperationId, Source site, Kind, Target, Status (badge + Stuck indicator), Retries, Last error, Created, Updated, Actions.
- Actions column: a **"View audit history"** link `href="/audit/log?correlationId=@row.TrackedOperationId"` (the `TrackedOperationId` is the audit `CorrelationId`) — mirror `NotificationReport.razor:172`; plus **Retry/Discard** buttons shown only on `Parked` rows (none on `Failed`).
- Keyset Previous/Next paging; double-click row → detail modal (body shows full row + LastError; reuse the Notifications detail-modal idiom — never `MarkupString`).
- Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor` — register the Site Calls page (own "Site Calls" section, or under an existing group, consistent with the `Notifications` / `Audit` section pattern at lines ~65129).
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — confirm `?correlationId=` drill-in already covers this (it does); no change expected — just verify.
- Test: `tests/ScadaLink.CentralUI.Tests/Pages/` (bUnit — scaffold, paging, parked-only actions, drill-in link), `tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs` (new).
**Use the `frontend-design` skill** for page/component styling guidance. Blazor Server + Bootstrap only; custom components; clean corporate aesthetic.
**Commit:** `feat(centralui): Site Calls page with Retry/Discard and Audit drill-in`
---
## Task 7: Site Call KPI tiles + Health dashboard integration
**What:** Surface Site Call Audit KPIs on the Health dashboard, mirroring the Notification Outbox tiles + `AuditKpiTiles`.
**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor` (+ `.razor.cs`) — mirror `Components/Health/AuditKpiTiles.razor`; tiles for Buffered, Parked (danger border if >0), Stuck (warning border if >0); each tile navigates to `/site-calls/report` with a query-string filter.
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor` (+ code-behind) — add a "Site Calls" KPI section between the Notification Outbox and Audit Log sections; load via `CommunicationService.GetSiteCallKpisAsync` (Task 4).
- Test: `tests/ScadaLink.CentralUI.Tests/` (bUnit — tile rendering, threshold borders, navigation targets).
**Commit:** `feat(centralui): Site Call KPI tiles on the Health dashboard`
---
## Task 8: Multi-value `AuditLogQueryFilter` — contract + repository
**What:** Widen `AuditLogQueryFilter` from single-value to multi-value on the `Channel`, `Kind`, `Status`, `SourceSiteId` dimensions, and translate them to `IN (...)` in the repository. `Target`, `Actor`, `CorrelationId`, `FromUtc`, `ToUtc` stay as-is. Keyset paging must not change.
**Files:**
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — change `Channel`/`Kind`/`Status`/`SourceSiteId` to `IReadOnlyList<…>?` (e.g. `IReadOnlyList<AuditChannel>? Channels`). Keep the record's other params. This is a **breaking shape change** — update every call site in this task.
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` (`QueryAsync`, ~lines 119165) — each widened dimension becomes `if (filter.Channels is { Count: > 0 }) query = query.Where(e => filter.Channels.Contains(e.Channel));`. Empty/null list = no filter. Keyset predicate + `OrderByDescending` untouched.
- Update all other `AuditLogQueryFilter` constructors in this task so the solution compiles (ManagementService `ParseFilter`, CentralUI `AuditQueryModel.ToFilter`, CLI helpers, tests) — the deep behaviour of those consumers is Task 9; here just make them compile (e.g. wrap a single value in a one-element list).
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — add `QueryAsync_FilterByMultipleChannels_ReturnsUnion`, multi-status, multi-site; keep the existing single-value and keyset tests green.
**Commit:** `feat(auditlog): multi-value AuditLogQueryFilter dimensions`
---
## Task 9: Multi-value filters — ManagementService, CLI, Central UI
**What:** Make the three consumers actually emit/accept multiple values per dimension instead of collapsing to the first.
**Files:**
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` (`ParseFilter`, ~lines 369414) — read repeated query params with `.ToArray()` (not `.ToString()`); parse each into the enum list; unparseable values silently dropped (keep the existing lax contract).
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs` (`ToFilter`, ~lines 110126) — stop collapsing to `.First()`; pass the full `Channels`/`Kinds`/`Statuses`/`SiteIdentifiers` sets. Adjust the `ErrorsOnly` logic (lines ~128145) for multi-value `Status`. The chip UI already supports multi-select — no `.razor` change expected; verify.
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` export-URL builder (~lines 175227) — emit repeated query-string params per selected value.
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` (~lines 2941) — make `--channel`/`--kind`/`--status`/`--site` accept multiple values (System.CommandLine multi-arity options; keep `AcceptOnlyFromAmong` for the enum-like ones). Modify `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs``AuditQueryArgs` fields become arrays; `BuildQueryString` emits one key per value.
- Test: extend `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`, `tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs`, `tests/ScadaLink.CentralUI.Tests/` filter-model tests for multi-value round-trips.
**Commit:** `feat(audit): multi-value filters across ManagementService, CLI and Central UI`
---
## Task 10: Audit results grid — column resize + reorder UX
**What:** Add drag-to-resize and drag-to-reorder column UX to `AuditResultsGrid`, persisted in `sessionStorage`. Blazor + Bootstrap + minimal JS interop only (no third-party libs).
**Files:**
- Create: `src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js` — a `window.auditGrid` namespace: column-resize drag handlers, header drag-reorder handlers, and `save(key,json)` / `load(key)` over `sessionStorage` (mirror `treeview-storage.js`).
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — render a resize handle in each `<th>`; make headers draggable; apply persisted widths (inline style/CSS var) and column order (the `ColumnOrder` parameter + `OrderedColumns()` already exist — wire it to persisted state); `IJSRuntime` calls to load on first render and save on change.
- Create: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css` — resize-handle styling, drag-over feedback (mirror `AuditDrilldownDrawer.razor.css` / `TreeView.razor.css` idioms).
- Reference the script from the host page (`App.razor` / `_Host` / layout — match where `monaco-init.js` / `session-expiry.js` are referenced).
- Test: extend `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs` (or new `AuditGridColumnTests.cs`) — resize changes a column width, reorder changes header order, both survive a reload via `sessionStorage`.
**Use the `frontend-design` skill** for the resize-handle / drag-feedback visual treatment.
**Commit:** `feat(centralui): column resize and reorder for the audit results grid`
---
## Final review
After Task 10: dispatch a final cross-cutting code review of the whole branch against this plan, then run the full solution build + test once more. Update `docs/plans/2026-05-20-audit-log-code-roadmap.md` header lines 1419 to strike the five now-completed follow-ups (leaving the three v1.x items). Hand back to the user for the push decision (do not push).
---
## Task dependency summary
- Task 0 blocks everything.
- Task 2 blocked by Task 1.
- Task 3 independent (after Task 0).
- Task 5 blocked by Task 4.
- Task 6 blocked by Tasks 4 and 5.
- Task 7 blocked by Task 4.
- Task 9 blocked by Task 8.
- Task 10 independent (after Task 0).
Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → final review.
@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-05-21-audit-log-followups.md",
"tasks": [
{"id": 33, "subject": "Task 0: Prep — feature branch", "status": "completed"},
{"id": 34, "subject": "Task 1: Audit push — central ingest routing over ClusterClient", "status": "completed", "blockedBy": [33]},
{"id": 35, "subject": "Task 2: Audit push — real site client, Host wiring, integration test", "status": "completed", "blockedBy": [34]},
{"id": 36, "subject": "Task 3: Consolidate the duplicated audit DTO mappers", "status": "completed", "blockedBy": [33]},
{"id": 37, "subject": "Task 4: Site Call Audit — query / KPI / detail backend", "status": "completed", "blockedBy": [33]},
{"id": 38, "subject": "Task 5: Site Call Audit — Retry/Discard relay to owning site", "status": "completed", "blockedBy": [37]},
{"id": 39, "subject": "Task 6: Site Calls UI page + nav + Audit drill-in", "status": "completed", "blockedBy": [37, 38]},
{"id": 40, "subject": "Task 7: Site Call KPI tiles + Health dashboard integration", "status": "completed", "blockedBy": [37]},
{"id": 41, "subject": "Task 8: Multi-value AuditLogQueryFilter — contract + repository", "status": "completed", "blockedBy": [33]},
{"id": 42, "subject": "Task 9: Multi-value filters — ManagementService, CLI, Central UI", "status": "completed", "blockedBy": [41]},
{"id": 43, "subject": "Task 10: Audit results grid — column resize + reorder UX", "status": "completed", "blockedBy": [33]}
],
"lastUpdated": "2026-05-21T12:00:00Z"
}
+24 -1
View File
@@ -83,6 +83,7 @@ row per lifecycle event across all channels.
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. | | `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). | | `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. | | `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. | | `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). | | `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. | | `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
@@ -102,7 +103,8 @@ row per lifecycle event across all channels.
- `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans. - `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans.
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters. - `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters.
- `IX_AuditLog_Correlation (CorrelationId)` — drilldown from a single operation. - `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation.
- `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request.
- `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles. - `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles.
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X". - `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge). - Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
@@ -126,6 +128,27 @@ Inbound API is intentionally collapsed to a single `InboundRequest` (or
`InboundAuthFailure` for auth rejections) row per request rather than a `InboundAuthFailure` for auth rejections) row per request rather than a
multi-event lifecycle. multi-event lifecycle.
### `ExecutionId` vs `CorrelationId`
The table carries two correlation columns at different granularities:
- **`ExecutionId`** is the *universal per-run* value: one id per script
execution (tag-change / timer-triggered or otherwise) or per inbound API
request. It is stamped on **every** audit row that run produces — the sync
`ApiCall` and `DbWrite` rows, the full cached-call lifecycle, the
`NotifySend` / `NotifyDeliver` rows, and the inbound row alike. A run that
performs no trust-boundary action emits no rows, but any run that emits
multiple rows ties them all together under one `ExecutionId`. This lets an
audit reader pull the complete trust-boundary footprint of a single script
run with one `ExecutionId` filter.
- **`CorrelationId`** is the *per-operation lifecycle* id — it groups the
multiple events of one long-running operation (`TrackedOperationId` for a
cached call, `NotificationId` for a notification, request-id for inbound
API) and is NULL for sync one-shot calls that have no operation lifecycle.
The two are orthogonal: one execution may touch several operations (each with
its own `CorrelationId`) yet every resulting row shares the one `ExecutionId`.
## The Site-Local `AuditLog` (SQLite) ## The Site-Local `AuditLog` (SQLite)
A SQLite database file on each site node, alongside the Store-and-Forward A SQLite database file on each site node, alongside the Store-and-Forward
+16 -4
View File
@@ -29,18 +29,30 @@ For `appsettings.Development.json` (Notification Service):
"Smtp": { "Smtp": {
"Server": "localhost", "Server": "localhost",
"Port": 1025, "Port": 1025,
"AuthMode": "None", "AuthMode": "Basic",
"Credentials": "test:test",
"TlsMode": "None",
"FromAddress": "scada-notifications@company.com", "FromAddress": "scada-notifications@company.com",
"ConnectionTimeout": 30 "ConnectionTimeout": 30
} }
} }
``` ```
Since `MP_SMTP_AUTH_ACCEPT_ANY` is enabled, the Notification Service can use any auth mode: > **`Server` host**: use `localhost` only when the Notification Service runs directly on
- **No auth**: Connect directly, no credentials needed. > the host. When it runs inside the docker cluster, set `Server` to the container name
- **Basic Auth**: Any username/password will be accepted (useful for testing the auth code path without a real server). > `scadalink-smtp` — the cluster compose stack and the infra compose stack share the
> `scadalink-net` network, so the container is reachable by name.
The delivery service (`MailKitSmtpClientWrapper`) only accepts `Basic` or `OAuth2`
there is no "no auth" mode — so the working config above uses `Basic`:
- **Basic Auth**: `MP_SMTP_AUTH_ACCEPT_ANY` makes Mailpit accept any `username:password`,
so use a throwaway value such as `test:test`. This exercises the real auth code path
without a real server.
- **OAuth2**: Not supported by Mailpit. For OAuth2 testing, use a real Microsoft 365 tenant. - **OAuth2**: Not supported by Mailpit. For OAuth2 testing, use a real Microsoft 365 tenant.
`TlsMode` **must** be `None`: Mailpit on port 1025 is plain SMTP and does not offer
STARTTLS. `StartTLS` or `SSL` would fail the connection.
## Mailpit API ## Mailpit API
Mailpit exposes a REST API at `http://localhost:8025/api` for programmatic access: Mailpit exposes a REST API at `http://localhost:8025/api` for programmatic access:
@@ -121,11 +121,14 @@ public static class ServiceCollectionExtensions
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(), logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
filter: sp.GetRequiredService<IAuditPayloadFilter>())); filter: sp.GetRequiredService<IAuditPayloadFilter>()));
// ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings // ISiteStreamAuditClient: NoOp default. This binding remains correct for
// the real gRPC-backed implementation (no site→central gRPC channel // central/test composition roots that have no SiteCommunicationActor.
// exists today — sites talk to central via Akka ClusterClient only). // The real implementation is ClusterClientSiteAuditClient, which pushes
// Bundle H's integration test substitutes a stub directly into the // audit telemetry to central over Akka ClusterClient via the site's
// SiteAuditTelemetryActor's Props.Create call. // SiteCommunicationActor — the Host wires it directly into the
// SiteAuditTelemetryActor's Props.Create call for site roles (it cannot
// be a DI singleton because it needs the SiteCommunicationActor IActorRef,
// created during Akka bootstrap, not at DI-composition time).
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>(); services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
// M3 Bundle F: site-side dual emitter for cached-call lifecycle // M3 Bundle F: site-side dual emitter for cached-call lifecycle
@@ -114,12 +114,53 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
PayloadTruncated INTEGER NOT NULL, PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL, Extra TEXT NULL,
ForwardState TEXT NOT NULL, ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
PRIMARY KEY (EventId) PRIMARY KEY (EventId)
); );
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc); ON AuditLog (ForwardState, OccurredAtUtc);
"""; """;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
// Audit Log #23 (ExecutionId): additively add the ExecutionId column.
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog
// table that already exists from a pre-ExecutionId build, so an
// auditlog.db created by an older build needs the column ALTER-ed in.
// The file is durable across restart/failover by design (7-day
// retention), so without this step every WriteAsync on an upgraded
// deployment would bind $ExecutionId against a missing column and the
// best-effort write path would silently drop every site audit row.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. The column is
// nullable with no default, so any row written before this migration
// reads back ExecutionId = null (back-compat).
AddColumnIfMissing("ExecutionId", "TEXT NULL");
}
/// <summary>
/// Audit Log #23 (ExecutionId): adds a column to <c>AuditLog</c> only when
/// it is not already present. SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
/// here to match the rest of this writer's bootstrap DDL.
/// </summary>
private void AddColumnIfMissing(string columnName, string columnDefinition)
{
using var probe = _connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
probe.Parameters.AddWithValue("$name", columnName);
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
if (exists)
{
return;
}
using var alter = _connection.CreateCommand();
// Column name + definition are caller-controlled constants, never user
// input — safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}";
alter.ExecuteNonQuery();
} }
/// <summary> /// <summary>
@@ -221,12 +262,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
EventId, OccurredAtUtc, Channel, Kind, CorrelationId, EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
) VALUES ( ) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, $EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target, $SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail, $Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
$ExecutionId
); );
"""; """;
@@ -250,6 +293,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer); var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text); var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text); var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
foreach (var pending in batch) foreach (var pending in batch)
{ {
@@ -274,6 +318,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0; pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
pExtra.Value = (object?)e.Extra ?? DBNull.Value; pExtra.Value = (object?)e.Extra ?? DBNull.Value;
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString(); pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
try try
{ {
@@ -331,7 +376,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog FROM AuditLog
WHERE ForwardState = $pending WHERE ForwardState = $pending
ORDER BY OccurredAtUtc ASC, EventId ASC ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -351,6 +397,55 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
} }
/// <summary>
/// Returns up to <paramref name="limit"/> rows in
/// <see cref="AuditForwardState.Forwarded"/>, oldest
/// <see cref="AuditEvent.OccurredAtUtc"/> first, with
/// <see cref="AuditEvent.EventId"/> as the deterministic tiebreaker. The
/// <see cref="AuditForwardState.Forwarded"/>-specific counterpart of
/// <see cref="ReadPendingAsync"/>; used by tests to assert a row reached the
/// <see cref="AuditForwardState.Forwarded"/> state specifically (unlike
/// <see cref="ReadPendingSinceAsync"/>, which also returns
/// <see cref="AuditForwardState.Pending"/> rows).
/// </summary>
public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default)
{
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// Mirror ReadPendingAsync: the write lock guards the single connection.
lock (_writeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog
WHERE ForwardState = $forwarded
ORDER BY OccurredAtUtc ASC, EventId ASC
LIMIT $limit;
""";
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
cmd.Parameters.AddWithValue("$limit", limit);
var rows = new List<AuditEvent>(Math.Min(limit, 256));
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(MapRow(reader));
}
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
}
}
/// <summary> /// <summary>
/// Flips the supplied EventIds from <see cref="AuditForwardState.Pending"/> to /// Flips the supplied EventIds from <see cref="AuditForwardState.Pending"/> to
/// <see cref="AuditForwardState.Forwarded"/> in a single UPDATE. Non-existent /// <see cref="AuditForwardState.Forwarded"/> in a single UPDATE. Non-existent
@@ -417,7 +512,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog FROM AuditLog
WHERE ForwardState IN ($pending, $forwarded) WHERE ForwardState IN ($pending, $forwarded)
AND OccurredAtUtc >= $since AND OccurredAtUtc >= $since
@@ -594,6 +690,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
PayloadTruncated = reader.GetInt32(17) != 0, PayloadTruncated = reader.GetInt32(17) != 0,
Extra = reader.IsDBNull(18) ? null : reader.GetString(18), Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)), ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
}; };
} }
@@ -133,9 +133,17 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
Channel = channel, Channel = channel,
Kind = kind, Kind = kind,
CorrelationId = context.TrackedOperationId.Value, CorrelationId = context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script
// execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat).
ExecutionId = context.ExecutionId,
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite, SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId, SourceInstanceId = context.SourceInstanceId,
SourceScript = null, // Not threaded through S&F; left null on retry-loop rows. // Audit Log #23 (ExecutionId Task 4): SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows.
SourceScript = context.SourceScript,
Target = context.Target, Target = context.Target,
Status = status, Status = status,
HttpStatus = httpStatus, HttpStatus = httpStatus,
@@ -34,15 +34,17 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
/// returns normally. /// returns normally.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Wire push deferred to M6.</b> M3 keeps this forwarder synchronous /// <b>Local-write only — the wire push is the drain actor's job.</b> This
/// against the local stores: there is no site→central gRPC channel yet, so /// forwarder is deliberately synchronous against the two site-local SQLite
/// the <see cref="ISiteStreamAuditClient.IngestCachedTelemetryAsync"/> RPC /// stores and never pushes to central itself. The site→central transport is
/// is registered on the interface (Bundle E1) but the production binding /// now live: <c>ClusterClientSiteAuditClient</c> is the production binding of
/// remains <c>NoOpSiteStreamAuditClient</c>. Once M6 wires a real client the /// <see cref="ISiteStreamAuditClient"/> on site roles (with
/// drain pattern from <c>SiteAuditTelemetryActor</c> can be reused — the /// <c>NoOpSiteStreamAuditClient</c> retained only for central/test composition
/// <c>AuditEvent</c> rows already live in SQLite tagged /// roots). The push happens out-of-band: <see cref="SiteAuditTelemetryActor"/>
/// <see cref="AuditForwardState.Pending"/>, so a single drain loop sweeps /// sweeps the <c>AuditEvent</c> rows this forwarder wrote — they live in SQLite
/// both M2 and M3 emissions. /// tagged <see cref="AuditForwardState.Pending"/> — and drains them to central
/// via that client. A single drain loop therefore covers both the audit-only
/// emissions and the cached-call emissions this forwarder produces.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
@@ -0,0 +1,117 @@
using Akka.Actor;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Communication.Grpc;
namespace ScadaLink.AuditLog.Site.Telemetry;
/// <summary>
/// Production <see cref="ISiteStreamAuditClient"/> binding for site composition
/// roots: pushes audit telemetry to central over Akka <c>ClusterClient</c> via
/// the site's <c>SiteCommunicationActor</c>. The actor forwards the command to
/// <c>/user/central-communication</c> and the central
/// <c>CentralCommunicationActor</c> Asks the <c>AuditLogIngestActor</c> proxy —
/// the same command/control transport notifications already use. Wired by the
/// Host for site roles; central and test composition roots keep the
/// <see cref="NoOpSiteStreamAuditClient"/> DI default (they have no
/// <c>SiteCommunicationActor</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Throw-on-failure contract.</b> An Ask timeout or a faulted reply
/// (<see cref="Status.Failure"/>) propagates as a thrown exception out of the
/// <c>Ingest*Async</c> methods — it is NOT caught and turned into an empty ack.
/// The <see cref="SiteAuditTelemetryActor"/> drain loop treats a thrown
/// exception as transient and leaves the rows <c>Pending</c> for the next tick.
/// Swallowing the fault into an empty ack would be indistinguishable from "zero
/// rows accepted" and would silently lose the retry signal. Task 1 confirmed
/// the central receiving end does not collapse an ingest fault into an empty
/// ack either, so a site-side Ask through the whole path faults cleanly on a
/// central-side timeout.
/// </para>
/// <para>
/// The batches arrive as proto DTOs (<see cref="AuditEventBatch"/> /
/// <see cref="CachedTelemetryBatch"/>) because the
/// <see cref="SiteAuditTelemetryActor"/> builds them with
/// <see cref="AuditEventDtoMapper.ToDto"/>. This client converts them back into
/// the <see cref="AuditEvent"/> / <see cref="SiteCall"/> entities the Akka
/// command messages carry — the same DTO→entity translation the
/// <c>SiteStreamGrpcServer</c> performs for the gRPC reconciliation path.
/// </para>
/// </remarks>
public sealed class ClusterClientSiteAuditClient : ISiteStreamAuditClient
{
private readonly IActorRef _siteCommunicationActor;
private readonly TimeSpan _askTimeout;
/// <param name="siteCommunicationActor">
/// The site's <c>SiteCommunicationActor</c> — it forwards the ingest command
/// over the registered central ClusterClient and routes the reply back to
/// this client's Ask.
/// </param>
/// <param name="askTimeout">
/// Ask timeout for the round-trip to central. On expiry the Ask throws
/// <see cref="Akka.Actor.AskTimeoutException"/>, which the drain loop treats
/// as transient (rows stay <c>Pending</c>).
/// </param>
public ClusterClientSiteAuditClient(IActorRef siteCommunicationActor, TimeSpan askTimeout)
{
ArgumentNullException.ThrowIfNull(siteCommunicationActor);
_siteCommunicationActor = siteCommunicationActor;
_askTimeout = askTimeout;
}
/// <inheritdoc/>
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(batch);
var events = new List<AuditEvent>(batch.Events.Count);
foreach (var dto in batch.Events)
{
events.Add(AuditEventDtoMapper.FromDto(dto));
}
// Ask<T> throws AskTimeoutException on timeout and rethrows a
// Status.Failure's inner cause — both surface as a thrown exception so
// the drain loop keeps the rows Pending. We deliberately do NOT catch.
var reply = await _siteCommunicationActor
.Ask<IngestAuditEventsReply>(new IngestAuditEventsCommand(events), _askTimeout, ct)
.ConfigureAwait(false);
return ToAck(reply.AcceptedEventIds);
}
/// <inheritdoc/>
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(batch);
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
foreach (var packet in batch.Packets)
{
var audit = AuditEventDtoMapper.FromDto(packet.AuditEvent);
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
entries.Add(new CachedTelemetryEntry(audit, siteCall));
}
// Same throw-on-failure contract as IngestAuditEventsAsync. The reply
// type is IngestCachedTelemetryReply (the central dual-write reply),
// distinct from IngestAuditEventsReply.
var reply = await _siteCommunicationActor
.Ask<IngestCachedTelemetryReply>(new IngestCachedTelemetryCommand(entries), _askTimeout, ct)
.ConfigureAwait(false);
return ToAck(reply.AcceptedEventIds);
}
private static IngestAck ToAck(IReadOnlyList<Guid> acceptedEventIds)
{
var ack = new IngestAck();
foreach (var id in acceptedEventIds)
{
ack.AcceptedEventIds.Add(id.ToString());
}
return ack;
}
}
@@ -3,40 +3,40 @@ using ScadaLink.Communication.Grpc;
namespace ScadaLink.AuditLog.Site.Telemetry; namespace ScadaLink.AuditLog.Site.Telemetry;
/// <summary> /// <summary>
/// Mockable abstraction over the central site-stream gRPC client surface that /// Mockable abstraction over the central site-audit push surface that
/// <see cref="SiteAuditTelemetryActor"/> uses to push <see cref="AuditEventBatch"/> /// <see cref="SiteAuditTelemetryActor"/> uses to forward <see cref="AuditEventBatch"/>
/// payloads. The production implementation (added in Bundle E host wiring) /// payloads. The production implementation is
/// wraps the auto-generated <c>SiteStreamService.SiteStreamServiceClient</c>; /// <see cref="ClusterClientSiteAuditClient"/> — a ClusterClient-based client,
/// unit tests substitute via NSubstitute against this interface so the actor /// wired in the Host for site roles, that forwards batches to central via the
/// never needs a live gRPC channel. /// site's <c>SiteCommunicationActor</c>. Unit tests substitute via NSubstitute
/// against this interface so the actor never needs a live transport.
/// </summary> /// </summary>
public interface ISiteStreamAuditClient public interface ISiteStreamAuditClient
{ {
/// <summary> /// <summary>
/// Pushes <paramref name="batch"/> to the central <c>IngestAuditEvents</c> /// Forwards <paramref name="batch"/> to the central audit-ingest path. The
/// RPC. The returned <see cref="IngestAck"/> carries the /// returned <see cref="IngestAck"/> carries the <c>accepted_event_ids</c>
/// <c>accepted_event_ids</c> the actor will flip to /// the actor will flip to
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/> /// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
/// in the site SQLite queue. /// in the site SQLite queue.
/// </summary> /// </summary>
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct); Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
/// <summary> /// <summary>
/// Pushes the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23 / M3) /// Forwards the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23)
/// to the central <c>IngestCachedTelemetry</c> RPC. Each packet carries both /// to the central cached-telemetry ingest path. Each packet carries both the
/// the audit row and the operational <c>SiteCalls</c> upsert; central writes /// audit row and the operational <c>SiteCalls</c> upsert; central writes both
/// both in a single MS SQL transaction. Returns the same /// in a single MS SQL transaction. Returns the same <see cref="IngestAck"/>
/// <see cref="IngestAck"/> shape as <see cref="IngestAuditEventsAsync"/> so /// shape as <see cref="IngestAuditEventsAsync"/> so the site-side forwarder
/// the M3 site-side forwarder can flip the underlying audit rows to /// can flip the underlying audit rows to
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/> /// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
/// once central has acknowledged them. /// once central has acknowledged them.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The production gRPC-backed implementation lands in M6 (no site→central /// The production <see cref="ClusterClientSiteAuditClient"/> forwards over
/// gRPC channel exists today); until then the default /// the ClusterClient transport; the <see cref="NoOpSiteStreamAuditClient"/>
/// <see cref="NoOpSiteStreamAuditClient"/> binding returns an empty ack and /// DI default (used by central and test composition roots) returns an empty
/// integration tests substitute a direct-actor client that routes the batch /// ack so no rows are flipped.
/// straight into the in-process <c>AuditLogIngestActor</c>.
/// </remarks> /// </remarks>
Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct); Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
} }
@@ -5,20 +5,18 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
/// <summary> /// <summary>
/// Default <see cref="ISiteStreamAuditClient"/> registered by /// Default <see cref="ISiteStreamAuditClient"/> registered by
/// <see cref="ScadaLink.AuditLog.ServiceCollectionExtensions.AddAuditLog"/>. /// <see cref="ScadaLink.AuditLog.ServiceCollectionExtensions.AddAuditLog"/>.
/// Ships with M2 site-sync-pipeline wiring; the real gRPC-backed /// It is a no-op binding for composition roots that have no
/// implementation is deferred to M6 reconciliation, where a site→central gRPC /// <c>SiteCommunicationActor</c> — central and test roots. Site roles override
/// channel will be introduced (no such channel exists today — sites talk to /// it in the Host with the ClusterClient-based
/// central exclusively via Akka ClusterClient, while the gRPC SiteStreamService /// <see cref="ClusterClientSiteAuditClient"/>, which actually forwards audit
/// is hosted on the SITE side for central→site streaming). /// telemetry to central.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Returns an empty <see cref="IngestAck"/> so the /// Returns an empty <see cref="IngestAck"/> so the
/// <see cref="SiteAuditTelemetryActor"/> doesn't flip any rows to /// <see cref="SiteAuditTelemetryActor"/> doesn't flip any rows to
/// <c>Forwarded</c> when this NoOp is in effect — Bundle H's integration test /// <c>Forwarded</c> when this NoOp is in effect — rows stay <c>Pending</c>
/// substitutes a stub client that routes directly to the central /// until a real client (or a test stub) takes over.
/// <c>AuditLogIngestActor</c> in-process. Production wiring (M6) will replace
/// this binding with a real client.
/// </para> /// </para>
/// <para> /// <para>
/// Audit-write paths are best-effort by contract: a NoOp client keeps the /// Audit-write paths are best-effort by contract: a NoOp client keeps the
@@ -35,7 +33,8 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
{ {
ArgumentNullException.ThrowIfNull(batch); ArgumentNullException.ThrowIfNull(batch);
// Empty ack — no EventIds will be flipped to Forwarded, so rows stay // Empty ack — no EventIds will be flipped to Forwarded, so rows stay
// Pending until M6's real client (or a Bundle H test stub) takes over. // Pending until the real ClusterClientSiteAuditClient (or a test stub)
// takes over.
return Task.FromResult(EmptyAck); return Task.FromResult(EmptyAck);
} }
@@ -43,11 +42,10 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
public Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct) public Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(batch); ArgumentNullException.ThrowIfNull(batch);
// Empty ack — same rationale as IngestAuditEventsAsync. The M3 // Empty ack — same rationale as IngestAuditEventsAsync. The site still
// CachedCallTelemetryForwarder still writes the audit + tracking rows to // writes the audit + tracking rows to its SQLite stores authoritatively;
// the site SQLite stores authoritatively; central-side state only // central-side state only materialises once the real
// materialises once M6's real gRPC client (or a Bundle G test stub) is // ClusterClientSiteAuditClient (or a test stub) is wired in.
// wired in.
return Task.FromResult(EmptyAck); return Task.FromResult(EmptyAck);
} }
} }
@@ -1,7 +1,6 @@
using Akka.Actor; using Akka.Actor;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Telemetry;
using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Communication.Grpc; using ScadaLink.Communication.Grpc;
@@ -136,7 +135,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
var batch = new AuditEventBatch(); var batch = new AuditEventBatch();
foreach (var e in events) foreach (var e in events)
{ {
batch.Events.Add(AuditEventMapper.ToDto(e)); batch.Events.Add(AuditEventDtoMapper.ToDto(e));
} }
return batch; return batch;
} }
+65 -16
View File
@@ -26,19 +26,40 @@ public static class AuditCommands
{ {
var sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" }; var sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" }; var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound)" }; // --channel/--kind/--status/--site are multi-valued: System.CommandLine accepts
// both repeated tokens (--channel A --channel B) and, with
// AllowMultipleArgumentsPerToken, a single token carrying several values
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
var channelOption = new Option<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
AllowMultipleArgumentsPerToken = true,
};
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound"); channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve)" }; var kindOption = new Option<string[]>("--kind")
{
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
AllowMultipleArgumentsPerToken = true,
};
kindOption.AcceptOnlyFromAmong( kindOption.AcceptOnlyFromAmong(
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend", "ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve"); "NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
var statusOption = new Option<string?>("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped)" }; var statusOption = new Option<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
AllowMultipleArgumentsPerToken = true,
};
statusOption.AcceptOnlyFromAmong( statusOption.AcceptOnlyFromAmong(
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped"); "Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" }; var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" }; var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" }; var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" }; var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" }; var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" }; var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
pageSizeOption.DefaultValueFactory = _ => 100; pageSizeOption.DefaultValueFactory = _ => 100;
@@ -54,6 +75,7 @@ public static class AuditCommands
cmd.Add(targetOption); cmd.Add(targetOption);
cmd.Add(actorOption); cmd.Add(actorOption);
cmd.Add(correlationIdOption); cmd.Add(correlationIdOption);
cmd.Add(executionIdOption);
cmd.Add(errorsOnlyOption); cmd.Add(errorsOnlyOption);
cmd.Add(pageSizeOption); cmd.Add(pageSizeOption);
cmd.Add(allOption); cmd.Add(allOption);
@@ -74,13 +96,14 @@ public static class AuditCommands
{ {
Since = result.GetValue(sinceOption), Since = result.GetValue(sinceOption),
Until = result.GetValue(untilOption), Until = result.GetValue(untilOption),
Channel = result.GetValue(channelOption), Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption), Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption), Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption), Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption), Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption), Actor = result.GetValue(actorOption),
CorrelationId = result.GetValue(correlationIdOption), CorrelationId = result.GetValue(correlationIdOption),
ExecutionId = result.GetValue(executionIdOption),
ErrorsOnly = result.GetValue(errorsOnlyOption), ErrorsOnly = result.GetValue(errorsOnlyOption),
PageSize = result.GetValue(pageSizeOption), PageSize = result.GetValue(pageSizeOption),
}; };
@@ -108,10 +131,36 @@ public static class AuditCommands
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true }; var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet"); formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true }; var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel" }; // --channel/--kind/--status/--site are multi-valued — same shape as the
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" }; // `query` subcommand: repeated tokens (--channel A --channel B) and, with
var statusOption = new Option<string?>("--status") { Description = "Filter by status" }; // AllowMultipleArgumentsPerToken, a single token carrying several values
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" }; // (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
var channelOption = new Option<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
AllowMultipleArgumentsPerToken = true,
};
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
var kindOption = new Option<string[]>("--kind")
{
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
AllowMultipleArgumentsPerToken = true,
};
kindOption.AcceptOnlyFromAmong(
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
var statusOption = new Option<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
AllowMultipleArgumentsPerToken = true,
};
statusOption.AcceptOnlyFromAmong(
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
var targetOption = new Option<string?>("--target") { Description = "Filter by target" }; var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" }; var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
@@ -142,10 +191,10 @@ public static class AuditCommands
Until = result.GetValue(untilOption)!, Until = result.GetValue(untilOption)!,
Format = result.GetValue(formatExportOption)!, Format = result.GetValue(formatExportOption)!,
Output = result.GetValue(outputOption)!, Output = result.GetValue(outputOption)!,
Channel = result.GetValue(channelOption), Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption), Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption), Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption), Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption), Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption), Actor = result.GetValue(actorOption),
}; };
@@ -6,6 +6,10 @@ namespace ScadaLink.CLI.Commands;
/// <summary> /// <summary>
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the /// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
/// Bundle B <c>GET /api/audit/export</c> parameters. /// Bundle B <c>GET /api/audit/export</c> parameters.
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set, exactly like
/// the <c>audit query</c> subcommand.
/// </summary> /// </summary>
public sealed class AuditExportArgs public sealed class AuditExportArgs
{ {
@@ -13,10 +17,10 @@ public sealed class AuditExportArgs
public string Until { get; set; } = string.Empty; public string Until { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty; public string Format { get; set; } = string.Empty;
public string Output { get; set; } = string.Empty; public string Output { get; set; } = string.Empty;
public string? Channel { get; set; } public string[] Channel { get; set; } = Array.Empty<string>();
public string? Kind { get; set; } public string[] Kind { get; set; } = Array.Empty<string>();
public string? Status { get; set; } public string[] Status { get; set; } = Array.Empty<string>();
public string? Site { get; set; } public string[] Site { get; set; } = Array.Empty<string>();
public string? Target { get; set; } public string? Target { get; set; }
public string? Actor { get; set; } public string? Actor { get; set; }
} }
@@ -31,7 +35,11 @@ public static class AuditExportHelpers
/// <summary> /// <summary>
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required /// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
/// time window + format, plus optional filters. Time-specs are resolved via /// time window + format, plus optional filters. Time-specs are resolved via
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>. /// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>. The multi-valued
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
/// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
/// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
/// <see cref="AuditQueryHelpers.BuildQueryString"/>.
/// </summary> /// </summary>
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now) public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
{ {
@@ -43,13 +51,21 @@ public static class AuditExportHelpers
parts.Add($"{key}={Uri.EscapeDataString(value)}"); parts.Add($"{key}={Uri.EscapeDataString(value)}");
} }
void AddEach(string key, IReadOnlyList<string> values)
{
foreach (var value in values)
{
Add(key, value);
}
}
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture)); Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture)); Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
Add("format", args.Format); Add("format", args.Format);
Add("channel", args.Channel); AddEach("channel", args.Channel);
Add("kind", args.Kind); AddEach("kind", args.Kind);
Add("status", args.Status); AddEach("status", args.Status);
Add("sourceSiteId", args.Site); AddEach("sourceSiteId", args.Site);
Add("target", args.Target); Add("target", args.Target);
Add("actor", args.Actor); Add("actor", args.Actor);
+36 -13
View File
@@ -9,18 +9,22 @@ namespace ScadaLink.CLI.Commands;
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B /// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/> /// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601). /// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set.
/// </summary> /// </summary>
public sealed class AuditQueryArgs public sealed class AuditQueryArgs
{ {
public string? Since { get; set; } public string? Since { get; set; }
public string? Until { get; set; } public string? Until { get; set; }
public string? Channel { get; set; } public string[] Channel { get; set; } = Array.Empty<string>();
public string? Kind { get; set; } public string[] Kind { get; set; } = Array.Empty<string>();
public string? Status { get; set; } public string[] Status { get; set; } = Array.Empty<string>();
public string? Site { get; set; } public string[] Site { get; set; } = Array.Empty<string>();
public string? Target { get; set; } public string? Target { get; set; }
public string? Actor { get; set; } public string? Actor { get; set; }
public string? CorrelationId { get; set; } public string? CorrelationId { get; set; }
public string? ExecutionId { get; set; }
public bool ErrorsOnly { get; set; } public bool ErrorsOnly { get; set; }
public int PageSize { get; set; } = 100; public int PageSize { get; set; } = 100;
} }
@@ -73,8 +77,11 @@ public static class AuditQueryHelpers
/// <summary> /// <summary>
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter /// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
/// args plus an optional keyset cursor. Unset filters are omitted. <c>--errors-only</c> /// args plus an optional keyset cursor. Unset filters are omitted. The multi-valued
/// maps to <c>status=Failed</c> (the server takes a single status value). /// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
/// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
/// server's multi-value <c>IN (…)</c> filter receives the full set. <c>--errors-only</c>
/// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
/// </summary> /// </summary>
public static string BuildQueryString( public static string BuildQueryString(
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
@@ -87,23 +94,39 @@ public static class AuditQueryHelpers
parts.Add($"{key}={Uri.EscapeDataString(value)}"); parts.Add($"{key}={Uri.EscapeDataString(value)}");
} }
void AddEach(string key, IReadOnlyList<string> values)
{
foreach (var value in values)
{
Add(key, value);
}
}
if (!string.IsNullOrWhiteSpace(args.Since)) if (!string.IsNullOrWhiteSpace(args.Since))
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture)); Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(args.Until)) if (!string.IsNullOrWhiteSpace(args.Until))
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture)); Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
Add("channel", args.Channel); AddEach("channel", args.Channel);
Add("kind", args.Kind); AddEach("kind", args.Kind);
// --errors-only is a convenience shorthand for the single-value Failed status // --errors-only is a convenience shorthand for the Failed status filter. The
// filter. The server's status filter accepts one value, so --errors-only and an // server's status filter is multi-value, but --errors-only stays a single-status
// explicit --status are mutually exclusive in effect; --errors-only wins. // override: it pins status=Failed and supersedes any explicit --status values.
Add("status", args.ErrorsOnly ? "Failed" : args.Status); if (args.ErrorsOnly)
{
Add("status", "Failed");
}
else
{
AddEach("status", args.Status);
}
Add("sourceSiteId", args.Site); AddEach("sourceSiteId", args.Site);
Add("target", args.Target); Add("target", args.Target);
Add("actor", args.Actor); Add("actor", args.Actor);
Add("correlationId", args.CorrelationId); Add("correlationId", args.CorrelationId);
Add("executionId", args.ExecutionId);
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
if (afterOccurredAtUtc.HasValue) if (afterOccurredAtUtc.HasValue)
@@ -69,33 +69,72 @@ public static class NotificationCommands
}); });
group.Add(listCmd); group.Add(listCmd);
var idOption = new Option<int>("--id") { Description = "SMTP config ID", Required = true };
var serverOption = new Option<string>("--server") { Description = "SMTP server", Required = true };
var portOption = new Option<int>("--port") { Description = "SMTP port", Required = true };
var authModeOption = new Option<string>("--auth-mode") { Description = "Auth mode", Required = true };
var fromOption = new Option<string>("--from-address") { Description = "From email address", Required = true };
var updateCmd = new Command("update") { Description = "Update SMTP configuration" }; var updateCmd = new Command("update") { Description = "Update SMTP configuration" };
updateCmd.Add(idOption); updateCmd.Add(SmtpIdOption);
updateCmd.Add(serverOption); updateCmd.Add(SmtpServerOption);
updateCmd.Add(portOption); updateCmd.Add(SmtpPortOption);
updateCmd.Add(authModeOption); updateCmd.Add(SmtpAuthModeOption);
updateCmd.Add(fromOption); updateCmd.Add(SmtpFromOption);
updateCmd.Add(SmtpTlsModeOption);
updateCmd.Add(SmtpCredentialsOption);
updateCmd.SetAction(async (ParseResult result) => updateCmd.SetAction(async (ParseResult result) =>
{ {
var id = result.GetValue(idOption);
var server = result.GetValue(serverOption)!;
var port = result.GetValue(portOption);
var authMode = result.GetValue(authModeOption)!;
var from = result.GetValue(fromOption)!;
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateSmtpConfigCommand(id, server, port, authMode, from)); BuildUpdateSmtpConfigCommand(result));
}); });
group.Add(updateCmd); group.Add(updateCmd);
return group; return group;
} }
// SMTP update options are static so the parsed values can be read back both
// from the SetAction and from BuildUpdateSmtpConfigCommand (used by tests).
private static readonly Option<int> SmtpIdOption =
new("--id") { Description = "SMTP config ID", Required = true };
private static readonly Option<string> SmtpServerOption =
new("--server") { Description = "SMTP server", Required = true };
private static readonly Option<int> SmtpPortOption =
new("--port") { Description = "SMTP port", Required = true };
private static readonly Option<string> SmtpAuthModeOption =
new("--auth-mode") { Description = "Auth mode", Required = true };
private static readonly Option<string> SmtpFromOption =
new("--from-address") { Description = "From email address", Required = true };
private static readonly Option<string?> SmtpTlsModeOption = CreateTlsModeOption();
private static readonly Option<string?> SmtpCredentialsOption =
new("--credentials")
{
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
"for OAuth2 (optional; preserves existing if omitted)",
};
private static Option<string?> CreateTlsModeOption()
{
var option = new Option<string?>("--tls-mode")
{
Description = "TLS mode: None, StartTLS, or SSL (optional; preserves existing if omitted)",
};
option.AcceptOnlyFromAmong("None", "StartTLS", "SSL");
return option;
}
/// <summary>
/// Builds the <see cref="UpdateSmtpConfigCommand"/> from a parsed <c>smtp update</c>
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> flags map to
/// null when omitted so the server-side handler preserves the existing values.
/// </summary>
internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result)
{
var id = result.GetValue(SmtpIdOption);
var server = result.GetValue(SmtpServerOption)!;
var port = result.GetValue(SmtpPortOption);
var authMode = result.GetValue(SmtpAuthModeOption)!;
var from = result.GetValue(SmtpFromOption)!;
var tlsMode = result.GetValue(SmtpTlsModeOption);
var credentials = result.GetValue(SmtpCredentialsOption);
return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials);
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var cmd = new Command("list") { Description = "List all notification lists" }; var cmd = new Command("list") { Description = "List all notification lists" };
+9 -4
View File
@@ -1078,10 +1078,10 @@ scadalink --url <url> audit query [options]
|--------|----------|---------|-------------| |--------|----------|---------|-------------|
| `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 | | `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 | | `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`) | | `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`); repeatable — multiple values are OR-combined |
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`) | | `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`); repeatable — multiple values are OR-combined |
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`) | | `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`); repeatable — multiple values are OR-combined |
| `--site` | no | — | Filter by source site ID | | `--site` | no | — | Filter by source site ID; repeatable — multiple values are OR-combined |
| `--target` | no | — | Filter by target (external system, DB connection, notification list) | | `--target` | no | — | Filter by target (external system, DB connection, notification list) |
| `--actor` | no | — | Filter by actor | | `--actor` | no | — | Filter by actor |
| `--correlation-id` | no | — | Filter by correlation ID | | `--correlation-id` | no | — | Filter by correlation ID |
@@ -1090,6 +1090,11 @@ scadalink --url <url> audit query [options]
| `--all` | no | `false` | Fetch every page, following the keyset cursor | | `--all` | no | `false` | Fetch every page, following the keyset cursor |
| `--format` | no | `json` | Output format: `json` (JSONL, one event per line) or `table` | | `--format` | no | `json` | Output format: `json` (JSONL, one event per line) or `table` |
The `--channel`/`--kind`/`--status`/`--site` filters accept multiple values —
either as repeated flags (`--channel ApiOutbound --channel DbOutbound`) or
space-separated after one flag (`--channel ApiOutbound DbOutbound`). Values
within one filter are OR-combined; the different filters are AND-combined.
With `--format table`, events render as an aligned text table with columns With `--format table`, events render as an aligned text table with columns
`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`, `OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`,
`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With `HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With
@@ -74,34 +74,27 @@ public static class AuditExportEndpoints
} }
/// <summary> /// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. /// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// Unknown enum names / un-parseable Guids / dates are silently dropped /// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c> dimensions are
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>). /// multi-value: a repeated query param yields a multi-element filter list, a
/// single param a one-element list. Unknown enum names / un-parseable Guids /
/// dates are silently dropped (same lax contract as
/// <c>AuditLogPage.ApplyQueryStringFilters</c>) — an unparseable value within
/// a repeated set is dropped, not the whole set.
/// </summary> /// </summary>
/// <remarks>
/// This endpoint reads the source-site filter from the <c>site</c> query key,
/// whereas the ManagementService export endpoint reads it as
/// <c>sourceSiteId</c>. The divergence is deliberate — each endpoint matches
/// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
/// </remarks>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query) internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{ {
AuditChannel? channel = null; var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
if (query.TryGetValue("channel", out var channelValues) var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
{ var sites = AuditQueryParamParsers.ParseStringList(query["site"]);
channel = parsedChannel;
}
AuditKind? kind = null;
if (query.TryGetValue("kind", out var kindValues)
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
{
kind = parsedKind;
}
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
string? site = TrimToNullable(query, "site");
string? target = TrimToNullable(query, "target"); string? target = TrimToNullable(query, "target");
string? actor = TrimToNullable(query, "actor"); string? actor = TrimToNullable(query, "actor");
@@ -112,17 +105,25 @@ public static class AuditExportEndpoints
correlationId = parsedCorr; correlationId = parsedCorr;
} }
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
DateTime? fromUtc = ParseUtcDate(query, "from"); DateTime? fromUtc = ParseUtcDate(query, "from");
DateTime? toUtc = ParseUtcDate(query, "to"); DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channel: channel, Channels: channels,
Kind: kind, Kinds: kinds,
Status: status, Statuses: statuses,
SourceSiteId: site, SourceSiteIds: sites,
Target: target, Target: target,
Actor: actor, Actor: actor,
CorrelationId: correlationId, CorrelationId: correlationId,
ExecutionId: executionId,
FromUtc: fromUtc, FromUtc: fromUtc,
ToUtc: toUtc); ToUtc: toUtc);
} }
@@ -55,6 +55,9 @@
<dt class="col-4 text-muted fw-normal">CorrelationId</dt> <dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd> <dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt> <dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd> <dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
@@ -151,6 +154,14 @@
Show all events for this operation Show all events for this operation
</button> </button>
} }
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-this-execution"
@onclick="ViewThisExecution">
View this execution
</button>
}
<button class="btn btn-primary btn-sm ms-auto" <button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer" data-test="drawer-close-footer"
@onclick="HandleClose"> @onclick="HandleClose">
@@ -47,9 +47,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <para> /// <para>
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set, /// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
/// the "Show all events" button navigates to /// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. The parent page does not /// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// auto-apply that filter today — it is a deep link the page can use /// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// when Bundle D wires up query-string deserialization. /// button navigates to <c>/audit/log?executionId={id}</c>. Both are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
/// </para> /// </para>
/// </summary> /// </summary>
public partial class AuditDrilldownDrawer public partial class AuditDrilldownDrawer
@@ -276,6 +277,20 @@ public partial class AuditDrilldownDrawer
Navigation.NavigateTo(uri); Navigation.NavigateTo(uri);
} }
/// <summary>
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
/// — the universal per-run correlation value, distinct from the per-operation
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
/// which the page parses on init and auto-loads. The button is only rendered
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewThisExecution()
{
if (Event?.ExecutionId is not { } exec) return;
var uri = $"/audit/log?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary> /// <summary>
/// Build a cURL command from an audit event. The URL comes from /// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as /// <c>Target</c>; when the RequestSummary parses as
@@ -6,78 +6,58 @@
<div class="card mb-3" data-test="audit-filter-bar"> <div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2"> <div class="card-body py-2">
@* Channel chip multi-select. *@ @* All filters sit in one wrapped row. Kind / Status / Site use compact
<div class="mb-2" data-test="filter-channel"> MultiSelectDropdown controls; Channel is a single-select because the
<label class="form-label small mb-1">Channel</label> Kind options narrow to the chosen channel — so the bar stays a row or
<div> two tall instead of four stacked blocks of chip buttons. *@
@foreach (var channel in Enum.GetValues<AuditChannel>())
{
var selected = _model.Channels.Contains(channel);
<button type="button" data-test="chip-channel-@channel"
class="@ChipClass(selected)"
@onclick="() => ToggleChannel(channel)">
@channel
</button>
}
</div>
</div>
@* Kind chip multi-select — narrowed by Channel selection. *@
<div class="mb-2" data-test="filter-kind">
<label class="form-label small mb-1">Kind</label>
<div>
@foreach (var kind in _model.VisibleKinds())
{
var selected = _model.Kinds.Contains(kind);
<button type="button" data-test="chip-kind-@kind"
class="@ChipClass(selected)"
@onclick="() => ToggleKind(kind)">
@kind
</button>
}
</div>
</div>
@* Status chip multi-select. *@
<div class="mb-2" data-test="filter-status">
<label class="form-label small mb-1">Status</label>
<div>
@foreach (var status in Enum.GetValues<AuditStatus>())
{
var selected = _model.Statuses.Contains(status);
<button type="button" data-test="chip-status-@status"
class="@ChipClass(selected)"
@onclick="() => ToggleStatus(status)">
@status
</button>
}
</div>
</div>
@* Site chip multi-select — populated from ISiteRepository. *@
<div class="mb-2" data-test="filter-site">
<label class="form-label small mb-1">Site</label>
<div>
@if (_sites.Count == 0)
{
<span class="text-muted small">No sites available.</span>
}
else
{
@foreach (var site in _sites)
{
var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
<button type="button" data-test="chip-site-@site.SiteIdentifier"
class="@ChipClass(selected)"
@onclick="() => ToggleSite(site.SiteIdentifier)">
@site.Name
</button>
}
}
</div>
</div>
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
@* Single-select: one channel at a time, so the Kind options below
narrow cleanly to that channel. "All channels" clears it. *@
<div class="col-auto" data-test="filter-channel">
<label class="form-label small mb-1" for="audit-channel">Channel</label>
<select id="audit-channel" data-test="filter-channel-select"
class="form-select form-select-sm" @bind="SelectedChannel">
<option value="">All channels</option>
@foreach (var channel in _channels)
{
<option value="@channel">@channel</option>
}
</select>
</div>
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
<div class="col-auto" data-test="filter-kind">
<label class="form-label small mb-1">Kind</label>
<div>
<MultiSelectDropdown TValue="AuditKind"
Items="_model.VisibleKinds()"
Selected="_model.Kinds"
DataTest="filter-kind-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-status">
<label class="form-label small mb-1">Status</label>
<div>
<MultiSelectDropdown TValue="AuditStatus"
Items="_statuses"
Selected="_model.Statuses"
DataTest="filter-status-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-site">
<label class="form-label small mb-1">Site</label>
<div>
<MultiSelectDropdown TValue="string"
Items="_siteIds"
Selected="_model.SiteIdentifiers"
Display="SiteName"
EmptyText="No sites available"
DataTest="filter-site-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-time-range"> <div class="col-auto" data-test="filter-time-range">
<label class="form-label small mb-1" for="audit-time-range">Time range</label> <label class="form-label small mb-1" for="audit-time-range">Time range</label>
<select id="audit-time-range" class="form-select form-select-sm" <select id="audit-time-range" class="form-select form-select-sm"
@@ -137,6 +117,16 @@
placeholder="contains…" @bind="_model.ActorSearch" /> placeholder="contains…" @bind="_model.ActorSearch" />
</div> </div>
@* ExecutionId is an exact-match Guid filter — the operator pastes the
universal per-run correlation value. Lax-parsed in ToFilter so a
blank/malformed paste simply drops the constraint. *@
<div class="col-auto" data-test="filter-execution-id">
<label class="form-label small mb-1" for="audit-execution-id">Execution ID</label>
<input id="audit-execution-id" type="text"
class="form-control form-control-sm font-monospace"
placeholder="paste GUID…" @bind="_model.ExecutionId" />
</div>
<div class="col-auto" data-test="filter-errors-only"> <div class="col-auto" data-test="filter-errors-only">
<div class="form-check mb-1"> <div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="audit-errors-only" <input class="form-check-input" type="checkbox" id="audit-errors-only"
@@ -7,19 +7,32 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the /// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements /// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// plus the Errors-only toggle, and publishes a collapsed /// — Channel as a single-select (one channel at a time, so the Kind options
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the /// narrow to it cleanly); Kind / Status / Site as compact
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select /// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// single-value collapse contract. /// controls; plus the time range, free-text searches and the Errors-only
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
/// dimensions map through to the filter's list fields; see
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
/// </summary> /// </summary>
public partial class AuditFilterBar public partial class AuditFilterBar
{ {
private readonly AuditQueryModel _model = new(); private readonly AuditQueryModel _model = new();
private List<Site> _sites = new(); private List<Site> _sites = new();
/// <summary>Channel options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditChannel> _channels = Enum.GetValues<AuditChannel>();
/// <summary>Status options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditStatus> _statuses = Enum.GetValues<AuditStatus>();
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
/// <summary> /// <summary>
/// Raised when the user clicks Apply. Carries the collapsed /// Raised when the user clicks Apply. Carries the
/// <see cref="AuditLogQueryFilter"/> the parent page hands to /// <see cref="AuditLogQueryFilter"/> the parent page hands to
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>. /// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
/// </summary> /// </summary>
@@ -51,10 +64,9 @@ public partial class AuditFilterBar
_model.InstanceSearch = InitialInstanceSearch.Trim(); _model.InstanceSearch = InitialInstanceSearch.Trim();
} }
// Populate the Site dropdown at component init. Failure is non-fatal — the
// Populate the Site chips at component init. Failure is non-fatal — the chip // dropdown just shows "No sites available." Sites are listed by Name to
// section just shows "No sites available." Sites are listed by Name to match // match operator expectations from the Notification Report.
// operator expectations from the Notification Report.
try try
{ {
var sites = await SiteRepository.GetAllSitesAsync(); var sites = await SiteRepository.GetAllSitesAsync();
@@ -62,48 +74,52 @@ public partial class AuditFilterBar
} }
catch catch
{ {
// Swallowed: filter bar still renders without the Site chips. The page // Swallowed: filter bar still renders without the Site options. The page
// surfaces site-load errors elsewhere (the grid query path). // surfaces site-load errors elsewhere (the grid query path).
_sites = new(); _sites = new();
} }
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
} }
private void ToggleChannel(AuditChannel channel) /// <summary>
/// Single-select Channel binding for the filter bar. The Audit Log filters one
/// channel at a time so the Kind options narrow cleanly to it; the model still
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
/// </summary>
private AuditChannel? SelectedChannel
{ {
if (!_model.Channels.Add(channel)) get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
set
{ {
_model.Channels.Remove(channel); _model.Channels.Clear();
} if (value is { } channel)
{
_model.Channels.Add(channel);
}
// Drop Kind chips that fall outside the new visible set. Keeps "Channel and OnChannelsChanged();
// Kind both picked" coherent — without this, removing a channel could leave }
// stale Kind chips selected that no longer match any visible chip. }
/// <summary>
/// Runs after the Channel selection changes. Drops any Kind selections that fell
/// outside the new visible set — without this, changing the channel could leave
/// stale Kind selections that no longer match any visible option.
/// </summary>
private void OnChannelsChanged()
{
var visible = _model.VisibleKinds().ToHashSet(); var visible = _model.VisibleKinds().ToHashSet();
_model.Kinds.RemoveWhere(k => !visible.Contains(k)); _model.Kinds.RemoveWhere(k => !visible.Contains(k));
} }
private void ToggleKind(AuditKind kind) /// <summary>Display label for a site identifier — its friendly Name, id as fallback.</summary>
private string SiteName(string siteIdentifier)
{ {
if (!_model.Kinds.Add(kind)) var site = _sites.FirstOrDefault(s =>
{ string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
_model.Kinds.Remove(kind); return site?.Name ?? siteIdentifier;
}
}
private void ToggleStatus(AuditStatus status)
{
if (!_model.Statuses.Add(status))
{
_model.Statuses.Remove(status);
}
}
private void ToggleSite(string siteIdentifier)
{
if (!_model.SiteIdentifiers.Add(siteIdentifier))
{
_model.SiteIdentifiers.Remove(siteIdentifier);
}
} }
private void ClearFilters() private void ClearFilters()
@@ -119,6 +135,7 @@ public partial class AuditFilterBar
_model.ScriptSearch = string.Empty; _model.ScriptSearch = string.Empty;
_model.TargetSearch = string.Empty; _model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty; _model.ActorSearch = string.Empty;
_model.ExecutionId = string.Empty;
_model.ErrorsOnly = false; _model.ErrorsOnly = false;
} }
@@ -129,11 +146,6 @@ public partial class AuditFilterBar
await OnFilterChanged.InvokeAsync(filter); await OnFilterChanged.InvokeAsync(filter);
} }
private static string ChipClass(bool selected) =>
selected
? "btn btn-sm btn-primary me-1 mb-1"
: "btn btn-sm btn-outline-secondary me-1 mb-1";
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
{ {
AuditTimeRangePreset.Last5Minutes => "now 5 min → now", AuditTimeRangePreset.Last5Minutes => "now 5 min → now",
@@ -15,20 +15,20 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is single-value /// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is multi-value
/// per dimension today; the chip multi-selects therefore collapse to the FIRST /// per dimension: the chip multi-selects map straight through to the
/// selected chip when the model is published via <see cref="ToFilter"/>. That is a /// <c>Channels</c> / <c>Kinds</c> / <c>Statuses</c> / <c>SourceSiteIds</c> filter
/// deliberate Bundle B scope decision — the chip UI is preserved so a follow-up can /// lists when the model is published via <see cref="ToFilter"/> — an empty set means
/// either repeat the query per chip or widen the filter contract without rewriting /// "do not constrain". Instance and Script free-text remain UI-only: the underlying
/// the form. Instance and Script free-text are also UI-only today: the underlying /// filter has no matching columns, so they are dropped when the model is published.
/// filter has no matching columns, so they are dropped during collapse.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// The Errors-only toggle is a convenience: when true AND no explicit Status chips /// The Errors-only toggle is a convenience: when true AND no explicit Status chips
/// are selected, the collapsed filter pins <see cref="AuditStatus.Failed"/> (the /// are selected, <see cref="ToFilter"/> targets the full error-status set
/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle /// {<see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
/// is a no-op — the explicit Status filter wins. /// <see cref="AuditStatus.Discarded"/>}. When Status chips ARE selected the toggle
/// is a no-op — the explicit Status chips win.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class AuditQueryModel public sealed class AuditQueryModel
@@ -47,6 +47,14 @@ public sealed class AuditQueryModel
public string TargetSearch { get; set; } = string.Empty; public string TargetSearch { get; set; } = string.Empty;
public string ActorSearch { get; set; } = string.Empty; public string ActorSearch { get; set; } = string.Empty;
/// <summary>
/// Paste-in ExecutionId filter — the operator pastes the universal per-run
/// correlation Guid. Stored as free text; <see cref="ToFilter"/> lax-parses it
/// through <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or
/// unparseable value simply yields no constraint.
/// </summary>
public string ExecutionId { get; set; } = string.Empty;
public bool ErrorsOnly { get; set; } public bool ErrorsOnly { get; set; }
/// <summary> /// <summary>
@@ -104,41 +112,51 @@ public sealed class AuditQueryModel
} }
/// <summary> /// <summary>
/// Collapses this UI model to the repository's single-value filter. /// Publishes this UI model as the repository's multi-value filter: each chip
/// See class doc for the multi-select → single-value contract. /// multi-select maps straight through to its filter list (an empty set yields
/// <c>null</c> — "do not constrain"). See class doc for the Errors-only rule.
/// </summary> /// </summary>
public AuditLogQueryFilter ToFilter(DateTime utcNow) public AuditLogQueryFilter ToFilter(DateTime utcNow)
{ {
var status = ResolveStatus(); var statuses = ResolveStatuses();
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow); var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
// Lax-parse the pasted ExecutionId — blank or malformed text yields no
// constraint rather than an error, mirroring the optional-filter contract.
Guid? executionId = Guid.TryParse(ExecutionId, out var parsedExecutionId)
? parsedExecutionId
: null;
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channel: Channels.Count > 0 ? Channels.First() : null, Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kind: Kinds.Count > 0 ? Kinds.First() : null, Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
Status: status, Statuses: statuses,
SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null, SourceSiteIds: SiteIdentifiers.Count > 0 ? SiteIdentifiers.ToArray() : null,
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(), Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(), Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null, CorrelationId: null,
ExecutionId: executionId,
FromUtc: fromUtc, FromUtc: fromUtc,
ToUtc: toUtc); ToUtc: toUtc);
} }
private AuditStatus? ResolveStatus() /// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>
private static readonly AuditStatus[] ErrorStatuses =
{ AuditStatus.Failed, AuditStatus.Parked, AuditStatus.Discarded };
private IReadOnlyList<AuditStatus>? ResolveStatuses()
{ {
if (Statuses.Count > 0) if (Statuses.Count > 0)
{ {
// Explicit chips win — Errors-only is a no-op. // Explicit chips win — Errors-only is a no-op.
return Statuses.First(); return Statuses.ToArray();
} }
if (ErrorsOnly) if (ErrorsOnly)
{ {
// Single-value filter contract: Failed is the lead non-success status. // Multi-value filter: Errors-only targets the full non-success set.
// When the filter widens to multi-value the full {Failed, Parked, Discarded} return ErrorStatuses;
// set will flow through.
return AuditStatus.Failed;
} }
return null; return null;
@@ -12,12 +12,26 @@
} }
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover align-middle"> <table class="table table-sm table-hover align-middle" @ref="_tableRef">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
@foreach (var col in OrderedColumns()) @foreach (var col in OrderedColumns())
{ {
<th data-test="col-header-@col.Key">@col.Label</th> // @key keeps Blazor reusing one DOM node per column across
// re-renders (reorder/resize), so audit-grid.js binds drag
// listeners exactly once per <th> and never leaks them onto
// discarded nodes — the __auditGridCellBound guard relies on
// this node stability to be fully sound.
<th class="audit-grid-th"
@key="col.Key"
data-test="col-header-@col.Key"
data-col-key="@col.Key"
style="@ColumnWidthStyle(col.Key)">
@col.Label
<span class="audit-grid-resize-handle"
data-test="col-resize-@col.Key"
aria-hidden="true"></span>
</th>
} }
</tr> </tr>
</thead> </thead>
@@ -48,7 +62,7 @@
@onclick="() => HandleRowClick(row)"> @onclick="() => HandleRowClick(row)">
@foreach (var col in OrderedColumns()) @foreach (var col in OrderedColumns())
{ {
<td> <td class="audit-grid-td" style="@ColumnWidthStyle(col.Key)">
@RenderCell(col.Key, row) @RenderCell(col.Key, row)
</td> </td>
} }
@@ -69,6 +83,15 @@
</div> </div>
@code { @code {
// Compact display for Guid id columns: the first 8 hex digits, mirroring
// the drilldown drawer's ShortEventId presentation. The full value is kept
// in the cell's title attribute so it stays copy-paste accessible.
private static string ShortGuid(Guid value)
{
var n = value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private RenderFragment RenderCell(string key, AuditEvent row) => __builder => private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
{ {
switch (key) switch (key)
@@ -97,6 +120,18 @@
case "Actor": case "Actor":
<span class="small">@(row.Actor ?? "—")</span> <span class="small">@(row.Actor ?? "—")</span>
break; break;
case "ExecutionId":
@if (row.ExecutionId is { } executionId)
{
<span class="small font-monospace"
data-test="execution-id-@row.EventId"
title="@executionId">@ShortGuid(executionId)</span>
}
else
{
<span class="small text-muted">—</span>
}
break;
case "DurationMs": case "DurationMs":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span> <span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break; break;
@@ -1,4 +1,6 @@
using System.Text.Json;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Enums;
@@ -7,19 +9,23 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). /// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
/// Renders the 10 columns named in Component-AuditLog.md §10: /// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs, /// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// HttpStatus, ErrorMessage. Talks to <see cref="Services.IAuditLogQueryService"/> /// ErrorMessage — plus the ExecutionId per-run correlation column. Talks to
/// <see cref="Services.IAuditLogQueryService"/>
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data /// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
/// source without standing up EF Core. /// source without standing up EF Core.
/// ///
/// <para> /// <para>
/// <b>Column model.</b> Each column has a stable string key; the visible order /// <b>Column model.</b> Each column has a stable string key. The default
/// is the <see cref="ColumnOrder"/> parameter. M7 scope: the column-model /// visible order is the <see cref="ColumnOrder"/> parameter (or the spec
/// framework is in place but resize / drag-reorder UX is intentionally NOT /// order from Component-AuditLog.md §10 when the parameter is null). On top of
/// implemented — the full spec calls for persisted-per-user reordering and /// that default the grid layers a per-browser override: drag-to-reorder and
/// resizing, which M7.x can ship without rewriting the column model. Resizing /// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column
/// today is CSS-based via Bootstrap's <c>.table-responsive</c> wrapper. /// widths to <c>sessionStorage</c>, and the grid restores them on first
/// render. A stored order that names an unknown/removed column degrades
/// gracefully — unknown keys are dropped, missing columns appended in default
/// order — so it never throws.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@@ -32,11 +38,28 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the /// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
/// end" signal for keyset paging without a count query. /// end" signal for keyset paging without a count query.
/// </para> /// </para>
///
/// <para>
/// <b>Accessibility.</b> Column resize and reorder are mouse/pointer-only —
/// they use a pointer-driven resize handle and native HTML5 drag-and-drop with
/// no keyboard equivalent and no ARIA for the reorder. This is a conscious
/// scope decision for an internal tool, not an oversight: only the column-
/// <i>customisation</i> gesture is mouse-only. The persisted layout itself
/// renders as plain HTML, so keyboard and assistive-technology users still get
/// a fully readable, navigable grid.
/// </para>
/// </summary> /// </summary>
public partial class AuditResultsGrid public partial class AuditResultsGrid : IAsyncDisposable
{ {
private const int DefaultPageSize = 100; private const int DefaultPageSize = 100;
/// <summary>Minimum persisted column width — mirrors <c>auditGrid.minWidth</c>.</summary>
private const int MinColumnWidthPx = 64;
/// <summary>sessionStorage keys (namespaced under <c>auditGrid:</c> by the JS helper).</summary>
private const string ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths";
private readonly List<AuditEvent> _rows = new(); private readonly List<AuditEvent> _rows = new();
private int _pageNumber = 1; private int _pageNumber = 1;
private bool _loading; private bool _loading;
@@ -44,6 +67,18 @@ public partial class AuditResultsGrid
private AuditLogQueryFilter? _activeFilter; private AuditLogQueryFilter? _activeFilter;
[Inject] private IJSRuntime JS { get; set; } = default!;
private ElementReference _tableRef;
private DotNetObjectReference<AuditResultsGrid>? _selfRef;
// Effective column state. _columnOrder is the live display order (seeded
// from the ColumnOrder parameter / spec default, then overridden by any
// persisted sessionStorage order). _columnWidths holds per-key pixel
// widths from a prior resize; absent keys render at auto width.
private List<string>? _columnOrder;
private readonly Dictionary<string, int> _columnWidths = new();
/// <summary> /// <summary>
/// Filter to apply. When this parameter changes the grid resets to page 1 and /// Filter to apply. When this parameter changes the grid resets to page 1 and
/// reissues the query — that's the contract the parent page relies on so the /// reissues the query — that's the contract the parent page relies on so the
@@ -75,6 +110,9 @@ public partial class AuditResultsGrid
/// <c>data-test</c> + the column-order parameter); the label is the user-facing /// <c>data-test</c> + the column-order parameter); the label is the user-facing
/// header text. Mirrors Component-AuditLog.md §10. /// header text. Mirrors Component-AuditLog.md §10.
/// </summary> /// </summary>
// Label intentionally equals Key for every column today; the separate Label
// field is future-proofing for humanised headers (e.g. "Occurred (UTC)") —
// populating it is a deliberate later change, out of scope here.
private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[] private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
{ {
("OccurredAtUtc", "OccurredAtUtc"), ("OccurredAtUtc", "OccurredAtUtc"),
@@ -84,30 +122,64 @@ public partial class AuditResultsGrid
("Status", "Status"), ("Status", "Status"),
("Target", "Target"), ("Target", "Target"),
("Actor", "Actor"), ("Actor", "Actor"),
("ExecutionId", "ExecutionId"),
("DurationMs", "DurationMs"), ("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"), ("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"), ("ErrorMessage", "ErrorMessage"),
}; };
private IReadOnlyList<(string Key, string Label)> OrderedColumns() private IReadOnlyList<(string Key, string Label)> OrderedColumns()
=> ResolveOrder(_columnOrder ?? ColumnOrder);
/// <summary>
/// Resolves a candidate list of column keys into the concrete display
/// columns. Degrades gracefully so a stale persisted order is never fatal:
/// unknown keys are dropped, and any column not named in the candidate
/// list is appended in its default (spec) position. A null/empty candidate
/// yields the full default order.
/// </summary>
private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList<string>? candidate)
{ {
if (ColumnOrder is null || ColumnOrder.Count == 0) if (candidate is null || candidate.Count == 0)
{ {
return AllColumns; return AllColumns;
} }
var byKey = AllColumns.ToDictionary(c => c.Key, c => c); var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
var ordered = new List<(string Key, string Label)>(ColumnOrder.Count); var ordered = new List<(string Key, string Label)>(AllColumns.Count);
foreach (var key in ColumnOrder) var seen = new HashSet<string>();
foreach (var key in candidate)
{ {
if (byKey.TryGetValue(key, out var col)) // Drop unknown keys (removed/renamed columns) and any duplicates.
if (byKey.TryGetValue(key, out var col) && seen.Add(key))
{ {
ordered.Add(col); ordered.Add(col);
} }
} }
return ordered.Count == 0 ? AllColumns : ordered;
// Append any columns the candidate omitted, in default order, so a
// newly-added column still appears after a restore of an older order.
foreach (var col in AllColumns)
{
if (seen.Add(col.Key))
{
ordered.Add(col);
}
}
return ordered;
} }
/// <summary>
/// Inline style for a column's cells: emits the <c>--audit-col-width</c>
/// custom property the scoped stylesheet reads, or an empty string when
/// the column has no persisted width (auto layout).
/// </summary>
private string ColumnWidthStyle(string key)
=> _columnWidths.TryGetValue(key, out var width)
? $"--audit-col-width: {width}px;"
: string.Empty;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter // Reset & reload whenever the filter reference changes. AuditLogQueryFilter
@@ -180,6 +252,179 @@ public partial class AuditResultsGrid
} }
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Restore any persisted order + widths first; the StateHasChanged
// inside triggers a re-render so the restored layout is on screen.
await LoadPersistedStateAsync();
_selfRef = DotNetObjectReference.Create(this);
}
// Wire (or re-wire) the JS drag handlers on every render. auditGrid.init
// is idempotent — already-bound cells are skipped, and the .NET
// reference is refreshed — so a re-render after a reorder still leaves
// every header cell wired without leaking handlers.
//
// OnColumnResized/OnColumnReordered both call StateHasChanged(), which
// re-runs this method and calls init again. That repeat call is an
// intentional cheap no-op: the @key-stable <th> nodes plus the
// __auditGridCellBound guard mean init re-scans the header and rebinds
// nothing — so there is deliberately no gating logic here.
if (_selfRef is not null)
{
try
{
await JS.InvokeVoidAsync("auditGrid.init", _tableRef, _selfRef);
}
catch (JSDisconnectedException)
{
// Circuit gone before init completed — nothing to wire.
}
}
}
/// <summary>
/// Reads the persisted column order + widths from <c>sessionStorage</c> and
/// applies them. A missing, empty, or corrupt payload is treated as "no
/// prior state" — the grid keeps its default order/widths and never throws.
/// </summary>
private async Task LoadPersistedStateAsync()
{
var orderJson = await TryLoadAsync(ColumnOrderStorageKey);
var widthsJson = await TryLoadAsync(ColumnWidthsStorageKey);
var changed = false;
if (!string.IsNullOrEmpty(orderJson))
{
try
{
var stored = JsonSerializer.Deserialize<List<string>>(orderJson);
if (stored is { Count: > 0 })
{
// Normalise through ResolveOrder so a stale key never sticks.
_columnOrder = ResolveOrder(stored).Select(c => c.Key).ToList();
changed = true;
}
}
catch (JsonException)
{
// Corrupt payload — ignore, keep the default order.
}
}
if (!string.IsNullOrEmpty(widthsJson))
{
try
{
var stored = JsonSerializer.Deserialize<Dictionary<string, int>>(widthsJson);
if (stored is not null)
{
var validKeys = AllColumns.Select(c => c.Key).ToHashSet();
_columnWidths.Clear();
foreach (var (key, width) in stored)
{
// Drop widths for unknown columns; clamp to the minimum.
if (validKeys.Contains(key))
{
_columnWidths[key] = Math.Max(MinColumnWidthPx, width);
}
}
changed = _columnWidths.Count > 0 || changed;
}
}
catch (JsonException)
{
// Corrupt payload — ignore, keep auto widths.
}
}
if (changed)
{
StateHasChanged();
}
}
private async Task<string?> TryLoadAsync(string key)
{
try
{
return await JS.InvokeAsync<string?>("auditGrid.load", key);
}
catch (JSDisconnectedException)
{
return null;
}
}
/// <summary>
/// JS callback: the user finished resizing a column. Persists the new
/// per-column width and re-renders so the body cells track the header.
/// </summary>
[JSInvokable]
public async Task OnColumnResized(string columnKey, int widthPx)
{
if (!AllColumns.Any(c => c.Key == columnKey))
{
return;
}
_columnWidths[columnKey] = Math.Max(MinColumnWidthPx, widthPx);
await SaveAsync(ColumnWidthsStorageKey, JsonSerializer.Serialize(_columnWidths));
StateHasChanged();
}
/// <summary>
/// JS callback: the user dropped column <paramref name="fromKey"/> onto the
/// header of <paramref name="toKey"/>. Moves the dragged column into the
/// target's slot, persists the resulting order, and re-renders.
/// </summary>
[JSInvokable]
public async Task OnColumnReordered(string fromKey, string toKey)
{
// Start from the current effective order so successive drags compose.
var order = OrderedColumns().Select(c => c.Key).ToList();
var fromIndex = order.IndexOf(fromKey);
var toIndex = order.IndexOf(toKey);
if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex)
{
return;
}
order.RemoveAt(fromIndex);
// After the removal the target index shifts left by one when the
// dragged column originally sat before it.
if (fromIndex < toIndex)
{
toIndex--;
}
order.Insert(toIndex, fromKey);
_columnOrder = order;
await SaveAsync(ColumnOrderStorageKey, JsonSerializer.Serialize(order));
StateHasChanged();
}
private async Task SaveAsync(string key, string json)
{
try
{
await JS.InvokeVoidAsync("auditGrid.save", key, json);
}
catch (JSDisconnectedException)
{
// Circuit gone — the in-memory state still drives this render.
}
}
public ValueTask DisposeAsync()
{
_selfRef?.Dispose();
return ValueTask.CompletedTask;
}
private static string StatusBadgeClass(AuditStatus status) => status switch private static string StatusBadgeClass(AuditStatus status) => status switch
{ {
AuditStatus.Delivered => "badge bg-success", AuditStatus.Delivered => "badge bg-success",
@@ -0,0 +1,82 @@
/* Audit results grid column resize + reorder UX (#23 follow-ups Task 10).
The base .table classes come from Bootstrap; the rules below add the
resize-handle affordance and the drag-to-reorder drop feedback. The
interaction itself lives in wwwroot/js/audit-grid.js this file is purely
the visual treatment. Internal-tool aesthetic: subtle, no flashy motion. */
/* A persisted width is delivered as the --audit-col-width custom property on
the <th> and matching <td> cells (set inline by the component / by
audit-grid.js during a drag). When present it pins the cell; when absent
the column falls back to Bootstrap auto-layout. The body cells also clip
overflowing text so a narrowed column stays tidy. */
.audit-grid-th[style*="--audit-col-width"],
.audit-grid-td[style*="--audit-col-width"] {
width: var(--audit-col-width);
min-width: var(--audit-col-width);
max-width: var(--audit-col-width);
}
.audit-grid-td[style*="--audit-col-width"] {
overflow: hidden;
text-overflow: ellipsis;
}
/* The header cell hosts the resize handle on its right edge, so it must be a
positioning context. Padding on the right is trimmed so the 6px handle does
not crowd the label text. */
.audit-grid-th {
position: relative;
padding-right: 0.75rem;
/* The whole header is draggable for reorder — a grab cursor signals it. */
cursor: grab;
user-select: none;
white-space: nowrap;
}
.audit-grid-th:active {
cursor: grabbing;
}
/* V resize handle. A thin invisible hit-strip on the right edge: 6px wide
for a comfortable grab target, transparent at rest so the header reads
clean. On hover a hairline primary rule fades in via the inset box-shadow
so the affordance is discoverable without being visually noisy. */
.audit-grid-resize-handle {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: col-resize;
/* Sit above the draggable header so a resize never starts a reorder. */
z-index: 1;
transition: box-shadow 0.08s linear, background-color 0.08s linear;
}
.audit-grid-resize-handle:hover {
/* Hairline rule centred on the strip's right edge. */
box-shadow: inset -2px 0 0 -1px rgba(var(--bs-primary-rgb), 0.55);
background-color: rgba(var(--bs-primary-rgb), 0.06);
}
/* While a drag-resize is in progress the column gets a steady primary rule on
its right edge so the user keeps a clear visual anchor. */
.audit-grid-th.resizing {
box-shadow: inset -2px 0 0 0 var(--bs-primary);
}
.audit-grid-th.resizing .audit-grid-resize-handle {
background-color: rgba(var(--bs-primary-rgb), 0.55);
}
/* V reorder feedback. The dragged header dims slightly; the prospective
drop target gets a left-edge accent rule + a faint info wash, matching the
TreeView drop-target idiom (a quiet, unmistakable cue, not an animation). */
.audit-grid-th.dragging {
opacity: 0.45;
}
.audit-grid-th.drop-target {
background-color: rgba(var(--bs-info-rgb), 0.18);
box-shadow: inset 2px 0 0 0 var(--bs-info);
}
@@ -0,0 +1,60 @@
@*
Site Call Audit (#22) Task 7 — three Health-dashboard KPI tiles for the
Site Call channel: Buffered / Parked / Stuck. Renders Bootstrap card tiles
in a single row, each acting as a navigation link to a pre-filtered Site
Calls report view. The component is purely presentational — the parent page
owns the refresh loop and passes the latest snapshot via the Snapshot
parameter. Mirrors AuditKpiTiles and the Notification Outbox KPI section.
*@
@namespace ScadaLink.CentralUI.Components.Health
@inject NavigationManager Navigation
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Site Calls</h6>
<a class="small" href="/site-calls/report">View details &rarr;</a>
</div>
<div class="row g-3 mb-3">
@* ── Buffered tile ─────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile"
data-test="site-call-kpi-buffered"
@onclick="NavigateToBuffered">
<div class="card-body text-center">
<h3 class="mb-0">@BufferedDisplay</h3>
<small class="text-muted">Buffered</small>
</div>
</button>
</div>
@* ── Stuck tile ────────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @StuckBorderClass"
data-test="site-call-kpi-stuck"
@onclick="NavigateToStuck">
<div class="card-body text-center">
<h3 class="mb-0 @StuckTextClass">@StuckDisplay</h3>
<small class="text-muted">Stuck</small>
</div>
</button>
</div>
@* ── Parked tile ───────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @ParkedBorderClass"
data-test="site-call-kpi-parked"
@onclick="NavigateToParked">
<div class="card-body text-center">
<h3 class="mb-0 @ParkedTextClass">@ParkedDisplay</h3>
<small class="text-muted">Parked</small>
</div>
</button>
</div>
</div>
@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage))
{
<div class="text-muted small mb-3">Site Call KPIs unavailable: @ErrorMessage</div>
}
@@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Components;
using ScadaLink.Commons.Messages.Audit;
namespace ScadaLink.CentralUI.Components.Health;
/// <summary>
/// Site Call Audit (#22) Task 7 code-behind for <see cref="SiteCallKpiTiles"/>.
/// Renders three KPI tiles — Buffered, Stuck, Parked — from a
/// <see cref="SiteCallKpiResponse"/> the parent Health dashboard supplies.
/// Tiles act as drill-in links: clicking navigates to <c>/site-calls/report</c>
/// with the relevant query-string filter pre-applied. Mirrors
/// <see cref="AuditKpiTiles"/> and the Notification Outbox KPI section on the
/// Health dashboard.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why purely presentational.</b> The Health dashboard already owns a 10s
/// auto-refresh loop; pushing that into the tile component would either
/// duplicate it (one timer per tile) or awkwardly couple back to the page. The
/// parent passes a fresh <see cref="SiteCallKpiResponse"/> every refresh and the
/// tile component re-renders. This is the same contract <see cref="AuditKpiTiles"/>
/// follows.
/// </para>
/// <para>
/// <b>Snapshot shape.</b> Unlike <see cref="AuditKpiTiles"/> — which takes a
/// dedicated <c>AuditLogKpiSnapshot</c> type — Site Call KPIs travel in the
/// <see cref="SiteCallKpiResponse"/> message itself (it carries the KPI fields
/// directly), so that record doubles as the snapshot here. <see cref="IsAvailable"/>
/// is a separate flag rather than the record's own <c>Success</c> so the parent
/// can also surface a transport failure (an Ask that threw) as unavailable.
/// </para>
/// <para>
/// <b>Threshold borders.</b> Mirrors the Notification Outbox tile pattern: the
/// Parked tile gets a danger border when <c>ParkedCount &gt; 0</c>; the Stuck
/// tile gets a warning border when <c>StuckCount &gt; 0</c>. Buffered is a plain
/// count tile with no threshold colour — a non-zero buffer is normal operation.
/// </para>
/// </remarks>
public partial class SiteCallKpiTiles
{
/// <summary>
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
/// or the load failed — the tiles render em dashes in that case.
/// </summary>
[Parameter] public SiteCallKpiResponse? Snapshot { get; set; }
/// <summary>
/// True when <see cref="Snapshot"/> is a successful query result. False when
/// the parent's refresh threw, or the response itself reported a fault, and
/// the displayed values should be rendered as em dashes with an error
/// explanation underneath.
/// </summary>
[Parameter] public bool IsAvailable { get; set; }
/// <summary>
/// Optional error message to render underneath the tiles when
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
/// section on the Health dashboard surfaces transient KPI failures.
/// </summary>
[Parameter] public string? ErrorMessage { get; set; }
// ── Buffered tile ───────────────────────────────────────────────────────
private string BufferedDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.BufferedCount.ToString("N0")
: "—";
private void NavigateToBuffered()
{
// Buffered is "everything still in flight" — no single status maps to
// it, so the natural drill-in is the unfiltered Site Calls report sorted
// by newest, mirroring how the Audit volume/backlog tiles drop the
// operator on the unfiltered Audit Log grid.
Navigation.NavigateTo("/site-calls/report");
}
// ── Stuck tile ──────────────────────────────────────────────────────────
private string StuckDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.StuckCount.ToString("N0")
: "—";
// Stuck above zero is a warning signal — cached calls that have been
// Pending/Retrying past the stuck-age threshold. Matches the Notification
// Outbox Stuck tile (border-warning when StuckCount > 0).
private string StuckBorderClass =>
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
? "border-warning"
: string.Empty;
private string StuckTextClass =>
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
? "text-warning"
: string.Empty;
private void NavigateToStuck()
{
// Drill in with the report's "stuck only" filter pre-applied.
Navigation.NavigateTo("/site-calls/report?stuck=true");
}
// ── Parked tile ─────────────────────────────────────────────────────────
private string ParkedDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.ParkedCount.ToString("N0")
: "—";
// Parked above zero is a danger signal — cached calls that exhausted retries
// and need an operator Retry/Discard. Matches the Notification Outbox Parked
// tile (border-danger when ParkedCount > 0).
private string ParkedBorderClass =>
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
? "border-danger"
: string.Empty;
private string ParkedTextClass =>
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
? "text-danger"
: string.Empty;
private void NavigateToParked()
{
// Drill in pre-filtered to Parked — the report's Status filter accepts
// ?status=Parked and Parked rows carry the Retry/Discard relay actions.
Navigation.NavigateTo("/site-calls/report?status=Parked");
}
}
@@ -91,6 +91,19 @@
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Site Calls — Site Call Audit (#22). Deployment-role only,
matching the Notification Report page's gate; the section
header sits inside the policy block so a non-Deployment
user does not see the heading. *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="siteCallsContext">
<div role="presentation" class="nav-section-header">Site Calls</div>
<li class="nav-item">
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
</li>
</Authorized>
</AuthorizeView>
@* Monitoring — Health Dashboard is all-roles; Event Logs and @* Monitoring — Health Dashboard is all-roles; Event Logs and
Parked Messages are Deployment-role only (Component-CentralUI). *@ Parked Messages are Deployment-role only (Component-CentralUI). *@
<div role="presentation" class="nav-section-header">Monitoring</div> <div role="presentation" class="nav-section-header">Monitoring</div>
@@ -19,10 +19,11 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <para> /// <para>
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can /// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>, /// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, and the UI-only /// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends /// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can /// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a /// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
/// <c>?executionId=</c> for the "View this execution" drill-in. When any param is present we allocate a
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to /// fresh <see cref="AuditLogQueryFilter"/> and assign it to
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load /// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name) /// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
@@ -60,6 +61,16 @@ public partial class AuditLogPage
correlationId = parsedCorr; correlationId = parsedCorr;
} }
// ?executionId= is the "View this execution" drill-in target — the
// universal per-run correlation value. Lax-parsed like ?correlationId=:
// an unparseable value is silently dropped (no constraint).
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
string? target = null; string? target = null;
if (query.TryGetValue("target", out var targetValues)) if (query.TryGetValue("target", out var targetValues))
{ {
@@ -80,33 +91,27 @@ public partial class AuditLogPage
} }
} }
string? site = null; // site/channel/kind/status accept repeated params for symmetry with the
if (query.TryGetValue("site", out var siteValues)) // multi-value export URL — a single ?site=/?channel=/?kind=/?status=
{ // drill-in still works (one-element list). Unknown enum names are silently
var v = siteValues.ToString(); // dropped. The lax-parse contract is shared with the two export endpoints
if (!string.IsNullOrWhiteSpace(v)) // via AuditQueryParamParsers so all three surfaces stay in lockstep.
{ IReadOnlyList<string>? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site"));
site = v.Trim();
}
}
AuditChannel? channel = null; IReadOnlyList<AuditChannel>? channels =
if (query.TryGetValue("channel", out var channelValues) AuditQueryParamParsers.ParseEnumList<AuditChannel>(Raw(query, "channel"));
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{ // ?kind= is honored for symmetry with BuildExportUrl, which emits a kind=
channel = parsedChannel; // param — a kind drill-in deep link must round-trip back into the filter.
} IReadOnlyList<AuditKind>? kinds =
AuditQueryParamParsers.ParseEnumList<AuditKind>(Raw(query, "kind"));
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in
// with ?status=Failed (and operators may craft URLs with Parked/Discarded). // with ?status=Failed (and operators may craft URLs with Parked/Discarded).
// Unknown values are silently dropped — the page still renders without // Unknown values are silently dropped — the page still renders without
// the constraint. // the constraint.
AuditStatus? status = null; IReadOnlyList<AuditStatus>? statuses =
if (query.TryGetValue("status", out var statusValues) AuditQueryParamParsers.ParseEnumList<AuditStatus>(Raw(query, "status"));
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
// Instance is UI-only — the filter contract has no matching column, so we // Instance is UI-only — the filter contract has no matching column, so we
// pass it as a separate seam to the filter bar. // pass it as a separate seam to the filter bar.
@@ -123,20 +128,34 @@ public partial class AuditLogPage
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load // auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
// because the filter contract has no instance column — the user still needs // because the filter contract has no instance column — the user still needs
// to refine + Apply for those. // to refine + Apply for those.
if (correlationId is null && target is null && actor is null && site is null && channel is null && status is null) if (correlationId is null && executionId is null && target is null && actor is null
&& sites is null && channels is null && kinds is null && statuses is null)
{ {
return; return;
} }
_currentFilter = new AuditLogQueryFilter( _currentFilter = new AuditLogQueryFilter(
Channel: channel, Channels: channels,
Status: status, Kinds: kinds,
SourceSiteId: site, Statuses: statuses,
SourceSiteIds: sites,
Target: target, Target: target,
Actor: actor, Actor: actor,
CorrelationId: correlationId); CorrelationId: correlationId,
ExecutionId: executionId);
} }
/// <summary>
/// Extracts the raw repeated values for one query-string key, returning
/// <c>null</c> when the key is absent so the shared
/// <see cref="AuditQueryParamParsers"/> sees the same absent-vs-present
/// distinction the ASP.NET <c>IQueryCollection</c> callers do.
/// <c>StringValues</c> is itself an <c>IEnumerable&lt;string?&gt;</c>.
/// </summary>
private static IEnumerable<string?>? Raw(
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
query.TryGetValue(key, out var values) ? (IEnumerable<string?>)values : null;
private void HandleFilterChanged(AuditLogQueryFilter filter) private void HandleFilterChanged(AuditLogQueryFilter filter)
{ {
// Always reassign — the grid keys reloads on reference change, so even a // Always reassign — the grid keys reloads on reference change, so even a
@@ -180,22 +199,42 @@ public partial class AuditLogPage
return basePath; return basePath;
} }
var parts = new List<KeyValuePair<string, string?>>(9); // No capacity hint: the dimensions are multi-value, so the part count is
if (filter.Channel is { } ch) // unbounded by the number of filter fields.
var parts = new List<KeyValuePair<string, string?>>();
// Task 9: the filter dimensions are multi-value end-to-end. Emit ONE
// repeated query-string key per selected value (channel=A&channel=B); the
// export endpoint's ParseFilter reads the full repeated set.
if (filter.Channels is { Count: > 0 } channels)
{ {
parts.Add(new("channel", ch.ToString())); foreach (var channel in channels)
{
parts.Add(new("channel", channel.ToString()));
}
} }
if (filter.Kind is { } kind) if (filter.Kinds is { Count: > 0 } kinds)
{ {
parts.Add(new("kind", kind.ToString())); foreach (var kind in kinds)
{
parts.Add(new("kind", kind.ToString()));
}
} }
if (filter.Status is { } status) if (filter.Statuses is { Count: > 0 } statuses)
{ {
parts.Add(new("status", status.ToString())); foreach (var status in statuses)
{
parts.Add(new("status", status.ToString()));
}
} }
if (!string.IsNullOrWhiteSpace(filter.SourceSiteId)) if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{ {
parts.Add(new("site", filter.SourceSiteId)); foreach (var site in sourceSiteIds)
{
if (!string.IsNullOrWhiteSpace(site))
{
parts.Add(new("site", site));
}
}
} }
if (!string.IsNullOrWhiteSpace(filter.Target)) if (!string.IsNullOrWhiteSpace(filter.Target))
{ {
@@ -209,6 +248,10 @@ public partial class AuditLogPage
{ {
parts.Add(new("correlationId", corr.ToString())); parts.Add(new("correlationId", corr.ToString()));
} }
if (filter.ExecutionId is { } exec)
{
parts.Add(new("executionId", exec.ToString()));
}
if (filter.FromUtc is { } from) if (filter.FromUtc is { } from)
{ {
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture))); parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
@@ -8,6 +8,7 @@
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.HealthMonitoring @using ScadaLink.HealthMonitoring
@using ScadaLink.Commons.Messages.Notification @using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Commons.Messages.Audit
@using ScadaLink.Communication @using ScadaLink.Communication
@implements IDisposable @implements IDisposable
@inject ICentralHealthAggregator HealthAggregator @inject ICentralHealthAggregator HealthAggregator
@@ -60,6 +61,12 @@
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div> <div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
} }
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel
(buffered / stuck / parked). Refreshed alongside the site states. *@
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
IsAvailable="@_siteCallKpiAvailable"
ErrorMessage="@_siteCallKpiError" />
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel @* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
(volume / error rate / backlog). Refreshed alongside the site states. *@ (volume / error rate / backlog). Refreshed alongside the site states. *@
<AuditKpiTiles Snapshot="@_auditKpi" <AuditKpiTiles Snapshot="@_auditKpi"
@@ -364,6 +371,13 @@
private bool _auditKpiAvailable; private bool _auditKpiAvailable;
private string? _auditKpiError; private string? _auditKpiError;
// Site Call Audit (#22) Task 7 — Site Call KPI tiles. Point-in-time counts
// from the central SiteCalls table, fetched alongside the site states. The
// SiteCallKpiResponse message doubles as the snapshot the tile takes.
private SiteCallKpiResponse? _siteCallKpi;
private bool _siteCallKpiAvailable;
private string? _siteCallKpiError;
private static bool SiteHasActiveErrors(SiteHealthState state) private static bool SiteHasActiveErrors(SiteHealthState state)
{ {
var report = state.LatestReport; var report = state.LatestReport;
@@ -401,6 +415,7 @@
{ {
_siteStates = HealthAggregator.GetAllSiteStates(); _siteStates = HealthAggregator.GetAllSiteStates();
await LoadOutboxKpis(); await LoadOutboxKpis();
await LoadSiteCallKpis();
await LoadAuditKpis(); await LoadAuditKpis();
} }
@@ -429,6 +444,36 @@
} }
} }
// Site Call KPI loader: wraps the service call so a transient fault degrades
// the three Site Call tiles to em dashes with an inline error rather than
// killing the dashboard. Mirrors LoadOutboxKpis's error handling shape — a
// response with Success == false (repository fault) and an Ask that threw
// (transport fault) both collapse to "unavailable".
private async Task LoadSiteCallKpis()
{
try
{
var response = await CommunicationService.GetSiteCallKpisAsync(
new SiteCallKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_siteCallKpi = response;
_siteCallKpiAvailable = true;
_siteCallKpiError = null;
}
else
{
_siteCallKpiAvailable = false;
_siteCallKpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_siteCallKpiAvailable = false;
_siteCallKpiError = $"KPI query failed: {ex.Message}";
}
}
// Tiles show the numeric KPI when available, or an em dash when the outbox // Tiles show the numeric KPI when available, or an em dash when the outbox
// KPI query failed — matching how the page renders other unavailable data. // KPI query failed — matching how the page renders other unavailable data.
private string OutboxTileValue(int value) => private string OutboxTileValue(int value) =>
@@ -139,7 +139,9 @@
<tbody> <tbody>
@foreach (var n in _notifications) @foreach (var n in _notifications)
{ {
<tr @key="n.NotificationId" class="@(n.IsStuck ? "table-warning" : "")"> <tr @key="n.NotificationId" class="@(n.IsStuck ? "table-warning" : "")"
style="cursor: pointer;" @ondblclick="() => ShowDetail(n)"
title="Double-click for full detail">
<td><code class="small" title="@n.NotificationId">@ShortId(n.NotificationId)</code></td> <td><code class="small" title="@n.NotificationId">@ShortId(n.NotificationId)</code></td>
<td>@n.Type</td> <td>@n.Type</td>
<td>@n.ListName</td> <td>@n.ListName</td>
@@ -162,7 +164,7 @@
<td><span class="small">@SiteName(n.SourceSiteId)</span></td> <td><span class="small">@SiteName(n.SourceSiteId)</span></td>
<td><TimestampDisplay Value="@n.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td> <td><TimestampDisplay Value="@n.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td>
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td> <td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
<td class="text-end"> <td class="text-end" @ondblclick:stopPropagation="true">
@* Bundle D (#23 M7-T10) drill-in: NotificationId is the audit @* Bundle D (#23 M7-T10) drill-in: NotificationId is the audit
CorrelationId, so the link deep-links into the central Audit CorrelationId, so the link deep-links into the central Audit
Log pre-filtered to this notification's lifecycle events. *@ Log pre-filtered to this notification's lifecycle events. *@
@@ -206,6 +208,144 @@
} }
</div> </div>
@* ── Row detail modal ── *@
@if (_detailNotification != null)
{
var d = _detailNotification;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Notification Detail — @ShortId(d.NotificationId)</h6>
<button type="button" class="btn-close" aria-label="Close"
@onclick="CloseDetail"></button>
</div>
<div class="modal-body">
<dl class="row mb-0">
<dt class="col-sm-3">Notification ID</dt>
<dd class="col-sm-9"><code>@d.NotificationId</code></dd>
<dt class="col-sm-3">Type</dt>
<dd class="col-sm-9">@d.Type</dd>
<dt class="col-sm-3">List</dt>
<dd class="col-sm-9">@d.ListName</dd>
<dt class="col-sm-3">Subject</dt>
<dd class="col-sm-9">@d.Subject</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge @StatusBadgeClass(d.Status)">@d.Status</span>
@if (d.IsStuck)
{
<span class="badge bg-warning text-dark ms-1">Stuck</span>
}
</dd>
<dt class="col-sm-3">Stuck</dt>
<dd class="col-sm-9">@(d.IsStuck ? "Yes" : "No")</dd>
<dt class="col-sm-3">Retry count</dt>
<dd class="col-sm-9 font-monospace">@d.RetryCount</dd>
<dt class="col-sm-3">Source site</dt>
<dd class="col-sm-9">@SiteName(d.SourceSiteId)</dd>
<dt class="col-sm-3">Source instance</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9"><TimestampDisplay Value="@d.CreatedAt" Format="yyyy-MM-dd HH:mm:ss" /></dd>
<dt class="col-sm-3">Delivered</dt>
<dd class="col-sm-9"><TimestampDisplay Value="@d.DeliveredAt" Format="yyyy-MM-dd HH:mm:ss" NullText="—" /></dd>
@if (!string.IsNullOrEmpty(d.LastError))
{
<dt class="col-sm-3">Last error</dt>
<dd class="col-sm-9 text-danger">@d.LastError</dd>
}
</dl>
@* ── Recipients ── *@
<hr />
<h6 class="mb-2">Recipients</h6>
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
var recipients = ParseRecipients(_detail.ResolvedTargets);
if (recipients.Count > 0)
{
<ul class="mb-0">
@foreach (var recipient in recipients)
{
<li>@recipient</li>
}
</ul>
}
else
{
<div class="text-muted small">
Not yet resolved — recipients are resolved from list
"@d.ListName" at delivery time.
</div>
}
}
@* ── Body ── *@
<hr />
<h6 class="mb-2">Message body</h6>
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
@* Email bodies are plain text (design: BCC delivery, plain text).
Rendered as preformatted text — never as a MarkupString, which
would be an XSS vector. *@
<pre class="border rounded bg-light p-2 mb-0"
style="max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word;">@_detail.Body</pre>
}
</div>
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
</div>
</div>
</div>
}
@code { @code {
private const int _pageSize = 50; private const int _pageSize = 50;
@@ -220,6 +360,12 @@
private string? _listError; private string? _listError;
private bool _actionInProgress; private bool _actionInProgress;
// Row detail modal
private NotificationSummary? _detailNotification;
private NotificationDetail? _detail;
private bool _detailLoading;
private string? _detailError;
// Filters // Filters
private string _statusFilter = string.Empty; private string _statusFilter = string.Empty;
private string _typeFilter = string.Empty; private string _typeFilter = string.Empty;
@@ -355,6 +501,95 @@
_actionInProgress = false; _actionInProgress = false;
} }
private async Task ShowDetail(NotificationSummary n)
{
// The summary fields render immediately; Body + recipients fill in once the
// full-detail fetch completes.
_detailNotification = n;
_detail = null;
_detailError = null;
_detailLoading = true;
StateHasChanged();
try
{
var response = await CommunicationService.GetNotificationDetailAsync(
new NotificationDetailRequest(Guid.NewGuid().ToString("N"), n.NotificationId));
if (response.Success && response.Detail != null)
{
_detail = response.Detail;
}
else
{
_detailError = response.ErrorMessage ?? "Failed to load notification detail.";
}
}
catch (Exception ex)
{
_detailError = $"Failed to load notification detail: {ex.Message}";
}
_detailLoading = false;
}
private void CloseDetail()
{
_detailNotification = null;
_detail = null;
_detailError = null;
_detailLoading = false;
}
/// <summary>
/// Best-effort parse of <c>ResolvedTargets</c> into individual recipient addresses.
/// The field may be a JSON string array, or a comma/semicolon-separated string.
/// Returns an empty list when null/empty.
/// </summary>
private static List<string> ParseRecipients(string? resolvedTargets)
{
if (string.IsNullOrWhiteSpace(resolvedTargets))
{
return new List<string>();
}
var trimmed = resolvedTargets.Trim();
if (trimmed.StartsWith('['))
{
try
{
var parsed = System.Text.Json.JsonSerializer.Deserialize<List<string>>(trimmed);
if (parsed != null)
{
return parsed
.Where(r => !string.IsNullOrWhiteSpace(r))
.Select(r => r.Trim())
.ToList();
}
}
catch (System.Text.Json.JsonException)
{
// Not valid JSON — fall through to the delimiter-split path.
}
}
return trimmed
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
}
private async Task RetryFromDetail(NotificationSummary n)
{
await RetryNotification(n);
// RefreshAll replaces the row list; close the modal so the user sees the
// refreshed grid rather than a now-stale detail snapshot.
CloseDetail();
}
private async Task DiscardFromDetail(NotificationSummary n)
{
await DiscardNotification(n);
CloseDetail();
}
private void ClearFilters() private void ClearFilters()
{ {
_statusFilter = string.Empty; _statusFilter = string.Empty;
@@ -50,6 +50,8 @@
<div class="col-md-8">@smtp.Host:@smtp.Port</div> <div class="col-md-8">@smtp.Host:@smtp.Port</div>
<div class="col-md-4 text-muted">Auth Type</div> <div class="col-md-4 text-muted">Auth Type</div>
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div> <div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
<div class="col-md-4 text-muted">TLS Mode</div>
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
<div class="col-md-4 text-muted">From Address</div> <div class="col-md-4 text-muted">From Address</div>
<div class="col-md-8">@smtp.FromAddress</div> <div class="col-md-8">@smtp.FromAddress</div>
<div class="col-md-4 text-muted">Credentials</div> <div class="col-md-4 text-muted">Credentials</div>
@@ -73,13 +75,21 @@
<label class="form-label">Port</label> <label class="form-label">Port</label>
<input type="number" class="form-control" @bind="_port" min="1" max="65535" /> <input type="number" class="form-control" @bind="_port" min="1" max="65535" />
</div> </div>
<div class="col-md-8"> <div class="col-md-4">
<label class="form-label">Auth Type</label> <label class="form-label">Auth Type</label>
<select class="form-select" @bind="_authType"> <select class="form-select" @bind="_authType">
<option>OAuth2</option> <option>OAuth2</option>
<option>Basic</option> <option>Basic</option>
</select> </select>
</div> </div>
<div class="col-md-4">
<label class="form-label">TLS Mode</label>
<select class="form-select" @bind="_tlsMode">
<option>None</option>
<option>StartTLS</option>
<option>SSL</option>
</select>
</div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Credentials</label> <label class="form-label">Credentials</label>
<input type="password" class="form-control" @bind="_credentials" <input type="password" class="form-control" @bind="_credentials"
@@ -122,6 +132,7 @@
private string _host = string.Empty; private string _host = string.Empty;
private int _port = 587; private int _port = 587;
private string _authType = "OAuth2"; private string _authType = "OAuth2";
private string? _tlsMode;
private string? _credentials; private string? _credentials;
private string _fromAddress = string.Empty; private string _fromAddress = string.Empty;
private string? _formError; private string? _formError;
@@ -154,6 +165,7 @@
_host = string.Empty; _host = string.Empty;
_port = 587; _port = 587;
_authType = "OAuth2"; _authType = "OAuth2";
_tlsMode = "None";
_credentials = null; _credentials = null;
_fromAddress = string.Empty; _fromAddress = string.Empty;
_formError = null; _formError = null;
@@ -166,6 +178,7 @@
_host = smtp.Host; _host = smtp.Host;
_port = smtp.Port; _port = smtp.Port;
_authType = smtp.AuthType; _authType = smtp.AuthType;
_tlsMode = smtp.TlsMode;
_credentials = smtp.Credentials; _credentials = smtp.Credentials;
_fromAddress = smtp.FromAddress; _fromAddress = smtp.FromAddress;
_formError = null; _formError = null;
@@ -194,6 +207,7 @@
_editingSmtp.Host = _host.Trim(); _editingSmtp.Host = _host.Trim();
_editingSmtp.Port = _port; _editingSmtp.Port = _port;
_editingSmtp.AuthType = _authType; _editingSmtp.AuthType = _authType;
_editingSmtp.TlsMode = _tlsMode;
_editingSmtp.Credentials = _credentials?.Trim(); _editingSmtp.Credentials = _credentials?.Trim();
_editingSmtp.FromAddress = _fromAddress.Trim(); _editingSmtp.FromAddress = _fromAddress.Trim();
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp); await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
@@ -203,6 +217,7 @@
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim()) var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
{ {
Port = _port, Port = _port,
TlsMode = _tlsMode,
Credentials = _credentials?.Trim() Credentials = _credentials?.Trim()
}; };
await NotificationRepository.AddSmtpConfigurationAsync(smtp); await NotificationRepository.AddSmtpConfigurationAsync(smtp);
@@ -0,0 +1,320 @@
@page "/site-calls/report"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Audit
@using ScadaLink.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject IDialogService Dialog
@inject ILogger<SiteCallsReport> Logger
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Calls</h4>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Refresh
</button>
</div>
@* ── Filters ── *@
<div class="card mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="sc-status">Status</label>
<select id="sc-status" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_statusFilter">
<option value="">All</option>
<option value="Submitted">Submitted</option>
<option value="Forwarded">Forwarded</option>
<option value="Attempted">Attempted</option>
<option value="Delivered">Delivered</option>
<option value="Parked">Parked</option>
<option value="Failed">Failed</option>
<option value="Discarded">Discarded</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-channel">Channel</label>
<select id="sc-channel" class="form-select form-select-sm" style="min-width: 130px;"
@bind="_channelFilter">
<option value="">All</option>
<option value="ApiOutbound">ApiOutbound</option>
<option value="DbOutbound">DbOutbound</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-site">Source site</label>
<select id="sc-site" class="form-select form-select-sm" style="min-width: 150px;"
@bind="_siteFilter">
<option value="">Any</option>
@foreach (var site in _sites)
{
<option value="@site.SiteIdentifier">@site.Name</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-from">From</label>
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
@bind="_fromFilter" />
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="sc-to">To</label>
<input id="sc-to" type="datetime-local" class="form-control form-control-sm"
@bind="_toFilter" />
</div>
<div class="col">
<label class="form-label small mb-1" for="sc-search">Target keyword</label>
<input id="sc-search" type="search" class="form-control form-control-sm"
placeholder="Exact target…" @bind="_targetFilter" />
</div>
<div class="col-auto">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="sc-stuck-only"
@bind="_stuckOnly" />
<label class="form-check-label small" for="sc-stuck-only">Stuck only</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
disabled="@(!HasActiveFilters)">Clear</button>
</div>
<div class="col-auto">
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_loading"
data-test="site-calls-query">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Query
</button>
</div>
</div>
</div>
</div>
@if (_listError != null)
{
<div class="alert alert-danger">@_listError</div>
}
@* ── Site call list ── *@
@if (_siteCalls == null)
{
@if (_loading)
{
<div class="text-muted small">Loading…</div>
}
}
else if (_siteCalls.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-5">
<div class="fs-5 mb-1">No site calls</div>
<div class="small">No cached calls match the current filters.</div>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Tracked operation</th>
<th>Source site</th>
<th>Channel</th>
<th>Target</th>
<th>Status</th>
<th class="text-end">Retries</th>
<th>Last error</th>
<th>Created</th>
<th>Updated</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var c in _siteCalls)
{
<tr @key="c.TrackedOperationId" class="@(c.IsStuck ? "table-warning" : "")"
style="cursor: pointer;" @ondblclick="() => ShowDetail(c)"
title="Double-click for full detail">
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
<td><span class="small">@SiteName(c.SourceSite)</span></td>
<td>@c.Channel</td>
<td>@c.Target</td>
<td>
<span class="badge @StatusBadgeClass(c.Status)">@c.Status</span>
@if (c.IsStuck)
{
<span class="badge bg-warning text-dark ms-1">Stuck</span>
}
</td>
<td class="text-end font-monospace">@c.RetryCount</td>
<td>
@if (!string.IsNullOrEmpty(c.LastError))
{
<div class="small text-danger text-truncate" style="max-width: 280px;"
title="@c.LastError">@c.LastError</div>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td><TimestampDisplay Value="@AsOffset(c.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
<td><TimestampDisplay Value="@AsOffset(c.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
<td class="text-end" @ondblclick:stopPropagation="true">
@* The TrackedOperationId is the audit CorrelationId, so the
link deep-links into the central Audit Log pre-filtered to
this cached call's lifecycle events. *@
<a class="btn btn-outline-secondary btn-sm me-1"
href="/audit/log?correlationId=@c.TrackedOperationId"
data-test="audit-link-@c.TrackedOperationId">
View audit history
</a>
@* Retry/Discard relay only on Parked rows — central relays the
action to the owning site; Failed and other statuses are not
actionable from central. *@
@if (c.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm me-1"
@onclick="() => RetrySiteCall(c)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardSiteCall(c)" disabled="@_actionInProgress">
Discard
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
@* Keyset paging — the Task 4 query response carries a (CreatedAtUtc, Id)
cursor rather than page numbers, so we keep a stack of cursors to step
backwards and the response's NextAfter* cursor to step forwards. *@
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">
@* No "of N" total: keyset paging has no cheap total-count, so
the label is intentionally page-number-only. Do not "fix"
this by adding a total — that would require a COUNT(*). *@
Page @(_cursorStack.Count + 1) · @_siteCalls.Count rows
</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="PrevPage" disabled="@(_cursorStack.Count == 0 || _loading)"
data-test="site-calls-prev">Previous</button>
<button class="btn btn-outline-secondary btn-sm"
@onclick="NextPage" disabled="@(!HasNextPage || _loading)"
data-test="site-calls-next">Next</button>
</div>
</div>
}
</div>
@* ── Row detail modal ── *@
@if (_detailSiteCall != null)
{
var d = _detailSiteCall;
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Site Call Detail — @ShortId(d.TrackedOperationId)</h6>
<button type="button" class="btn-close" aria-label="Close"
@onclick="CloseDetail"></button>
</div>
<div class="modal-body">
@if (_detailLoading)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Loading details…
</div>
}
else if (_detailError != null)
{
<div class="text-danger small">@_detailError</div>
}
else if (_detail != null)
{
var det = _detail;
<dl class="row mb-0">
<dt class="col-sm-3">Tracked operation</dt>
<dd class="col-sm-9"><code>@det.TrackedOperationId</code></dd>
<dt class="col-sm-3">Source site</dt>
<dd class="col-sm-9">@SiteName(det.SourceSite)</dd>
<dt class="col-sm-3">Channel</dt>
<dd class="col-sm-9">@det.Channel</dd>
<dt class="col-sm-3">Target</dt>
<dd class="col-sm-9">@det.Target</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge @StatusBadgeClass(det.Status)">@det.Status</span>
</dd>
<dt class="col-sm-3">Retry count</dt>
<dd class="col-sm-9 font-monospace">@det.RetryCount</dd>
<dt class="col-sm-3">HTTP status</dt>
<dd class="col-sm-9">@(det.HttpStatus?.ToString() ?? "—")</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
<dt class="col-sm-3">Updated</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
<dt class="col-sm-3">Terminal</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.TerminalAtUtc)"
Format="yyyy-MM-dd HH:mm:ss" NullText="—" />
</dd>
<dt class="col-sm-3">Ingested (central)</dt>
<dd class="col-sm-9">
<TimestampDisplay Value="@AsOffset(det.IngestedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
</dd>
@if (!string.IsNullOrEmpty(det.LastError))
{
<dt class="col-sm-3">Last error</dt>
@* Plain text — never a MarkupString. *@
<dd class="col-sm-9 text-danger">@det.LastError</dd>
}
</dl>
}
</div>
<div class="modal-footer">
@if (d.Status == "Parked")
{
<button class="btn btn-outline-success btn-sm"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
Discard
</button>
}
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,446 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Messages.Audit;
namespace ScadaLink.CentralUI.Components.Pages.SiteCalls;
/// <summary>
/// Code-behind for the central Site Calls report page (Site Call Audit #22). A
/// near-mirror of <see cref="ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport"/>:
/// it queries the central <c>SiteCalls</c> table via
/// <see cref="ScadaLink.Communication.CommunicationService.QuerySiteCallsAsync"/>,
/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard
/// of <c>Parked</c> cached calls to their owning site.
///
/// <para>
/// Unlike the Notification report, the query response uses a <c>(CreatedAtUtc DESC,
/// TrackedOperationId DESC)</c> keyset cursor rather than page numbers, so paging
/// keeps a stack of the cursors that opened each page (to step backwards) plus the
/// response's <c>NextAfter*</c> cursor (to step forwards).
/// </para>
///
/// <para>
/// Retry/Discard relay to the owning site has a distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/>
/// outcome — central is an eventually-consistent mirror, not the source of truth, so
/// a relay that never reaches the site is a transient transport condition, surfaced
/// to the operator differently from a generic failure.
/// </para>
///
/// <para>
/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
/// with <c>?status=Parked</c> (Parked tile) or <c>?stuck=true</c> (Stuck tile). On
/// initialization those params seed <see cref="_statusFilter"/> / <see cref="_stuckOnly"/>
/// BEFORE the first <see cref="RefreshAll"/>, so the first grid load is already
/// filtered and the filter card controls reflect the seeded values. Parsing is lax
/// — an absent, blank, or unrecognised value is silently dropped and the page loads
/// unfiltered, mirroring <c>AuditLogPage</c>'s drill-in convention.
/// </para>
/// </summary>
public partial class SiteCallsReport
{
private const int PageSize = 50;
[Inject] private NavigationManager Navigation { get; set; } = null!;
// The Status filter <select> options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
// filter when it matches one of these (case-insensitively); anything else is
// dropped so a hand-crafted bad URL still renders the page unfiltered.
private static readonly string[] ValidStatuses =
{
"Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
};
private ToastNotification _toast = default!;
private List<Site> _sites = new();
// List
private List<SiteCallSummary>? _siteCalls;
private bool _loading;
private string? _listError;
private bool _actionInProgress;
// Keyset paging. The first page is opened with the empty (null, null) cursor.
// _cursorStack holds the cursors of the PREVIOUSLY visited pages — it is empty
// on page 1, has one entry on page 2, and so on; Previous pops it. _nextCursor
// is the cursor for the following page, echoed back by the last query.
private readonly Stack<(DateTime? AfterCreatedAtUtc, Guid? AfterId)> _cursorStack = new();
private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null);
private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor;
// Row detail modal
private SiteCallSummary? _detailSiteCall;
private SiteCallDetail? _detail;
private bool _detailLoading;
private string? _detailError;
// Filters
private string _statusFilter = string.Empty;
private string _channelFilter = string.Empty;
private string _siteFilter = string.Empty;
private string _targetFilter = string.Empty;
private bool _stuckOnly;
private DateTime? _fromFilter;
private DateTime? _toFilter;
private bool HasNextPage => _nextCursor is not null;
protected override async Task OnInitializedAsync()
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
}
catch (Exception ex)
{
// Non-fatal — the source-site filter just falls back to raw site IDs.
Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
}
// Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
// grid load is already filtered (and the filter card controls reflect it).
ApplyQueryStringFilters();
await RefreshAll();
}
/// <summary>
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
/// string. <c>?status=&lt;status&gt;</c> seeds <see cref="_statusFilter"/> when it
/// matches a known status (case-insensitive); <c>?stuck=true</c> seeds
/// <see cref="_stuckOnly"/>. Lax parsing — an absent, blank, or unrecognised value
/// is silently dropped, leaving the filter empty (the no-param behaviour).
/// </summary>
private void ApplyQueryStringFilters()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.Count == 0)
{
return;
}
if (query.TryGetValue("status", out var statusValues))
{
var v = statusValues.ToString();
// Round-trip the dropdown's own option strings (the KPI tile emits the
// canonical casing, e.g. ?status=Parked); normalise to that casing so the
// <select> binds. An unrecognised value leaves the filter unset.
var match = ValidStatuses.FirstOrDefault(
s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
_statusFilter = match;
}
}
if (query.TryGetValue("stuck", out var stuckValues)
&& bool.TryParse(stuckValues.ToString(), out var stuck))
{
_stuckOnly = stuck;
}
}
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
private async Task RefreshAll()
{
await FetchPage(_currentCursor);
}
/// <summary>Apply the filters and start again from the first page.</summary>
private async Task Search()
{
_cursorStack.Clear();
await FetchPage((null, null));
}
private async Task PrevPage()
{
if (_cursorStack.Count == 0)
{
return;
}
// The top of the stack is the cursor of the page BEFORE the current one.
var previousCursor = _cursorStack.Pop();
await FetchPage(previousCursor);
}
private async Task NextPage()
{
if (_nextCursor is not { } next)
{
return;
}
// Stepping forward: remember the current page's cursor so Previous can
// return to it.
_cursorStack.Push(_currentCursor);
await FetchPage(next);
}
/// <summary>
/// Fetch one keyset page starting after <paramref name="cursor"/>.
/// </summary>
private async Task FetchPage(
(DateTime? AfterCreatedAtUtc, Guid? AfterId) cursor)
{
_loading = true;
_listError = null;
try
{
var request = new SiteCallQueryRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
StatusFilter: NullIfEmpty(_statusFilter),
SourceSiteFilter: NullIfEmpty(_siteFilter),
ChannelFilter: NullIfEmpty(_channelFilter),
TargetKeyword: NullIfEmpty(_targetFilter),
StuckOnly: _stuckOnly,
FromUtc: ToUtc(_fromFilter),
ToUtc: ToUtc(_toFilter),
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
AfterId: cursor.AfterId,
PageSize: PageSize);
var response = await CommunicationService.QuerySiteCallsAsync(request);
if (response.Success)
{
_siteCalls = response.SiteCalls.ToList();
_currentCursor = cursor;
// The response echoes the last row's cursor. A short page (fewer
// rows than requested) has no further page even if a cursor came
// back, so gate Next on a full page too.
_nextCursor = response.NextAfterCreatedAtUtc is { } nextCreated
&& response.NextAfterId is { } nextId
&& _siteCalls.Count == PageSize
? (nextCreated, nextId)
: null;
}
else
{
_listError = response.ErrorMessage ?? "Query failed.";
}
}
catch (Exception ex)
{
_listError = $"Query failed: {ex.Message}";
}
_loading = false;
}
private async Task RetrySiteCall(SiteCallSummary c)
{
var confirmed = await Dialog.ConfirmAsync(
"Retry cached call",
$"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
$"to site {SiteName(c.SourceSite)}?");
if (!confirmed) return;
_actionInProgress = true;
try
{
var response = await CommunicationService.RetrySiteCallAsync(
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
if (response.Success)
{
await RefreshAll();
}
}
catch (Exception ex)
{
_toast.ShowError($"Retry failed: {ex.Message}");
}
_actionInProgress = false;
}
private async Task DiscardSiteCall(SiteCallSummary c)
{
var confirmed = await Dialog.ConfirmAsync(
"Discard cached call",
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
danger: true);
if (!confirmed) return;
_actionInProgress = true;
try
{
var response = await CommunicationService.DiscardSiteCallAsync(
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
if (response.Success)
{
await RefreshAll();
}
}
catch (Exception ex)
{
_toast.ShowError($"Discard failed: {ex.Message}");
}
_actionInProgress = false;
}
/// <summary>
/// Surface a relay outcome on the toast — exactly one toast per relay
/// response. The <see cref="SiteCallRelayOutcome.SiteUnreachable"/> case is
/// deliberately distinct from a generic failure: the action was not applied
/// but the operator can retry once the site is back online.
/// </summary>
/// <remarks>
/// The <see cref="SiteCallRelayOutcome"/> switch is exhaustive, so it owns
/// the single toast. <paramref name="siteReachable"/> is a redundant
/// cross-check on the same signal (the contract sets it <c>false</c> only
/// for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>); it is folded
/// INTO the <see cref="SiteCallRelayOutcome.OperationFailed"/> case rather
/// than firing a second toast — an <c>OperationFailed</c> response that also
/// reports an unreachable site shows the unreachable wording, once.
/// </remarks>
private void ShowRelayOutcome(
SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage)
{
switch (outcome)
{
case SiteCallRelayOutcome.Applied:
_toast.ShowSuccess(appliedMessage);
break;
case SiteCallRelayOutcome.NotParked:
_toast.ShowInfo(errorMessage
?? "The site reported nothing to do — the cached call is no longer parked.");
break;
case SiteCallRelayOutcome.SiteUnreachable:
_toast.ShowError(errorMessage
?? "Site unreachable — the relay did not reach the owning site. "
+ "Try again once the site is back online.");
break;
case SiteCallRelayOutcome.OperationFailed when !siteReachable:
// An OperationFailed response that nonetheless reports the site
// unreachable: trust the reachability signal and show the
// unreachable wording instead of the generic failure message.
_toast.ShowError(errorMessage
?? "Site unreachable — the relay did not reach the owning site. "
+ "Try again once the site is back online.");
break;
case SiteCallRelayOutcome.OperationFailed:
default:
_toast.ShowError(errorMessage ?? "The site could not apply the action.");
break;
}
}
private async Task ShowDetail(SiteCallSummary c)
{
// The summary fields render immediately from the grid row; the full detail
// (HttpStatus, all timestamps, LastError) fills in once the fetch completes.
_detailSiteCall = c;
_detail = null;
_detailError = null;
_detailLoading = true;
StateHasChanged();
try
{
var response = await CommunicationService.GetSiteCallDetailAsync(
new SiteCallDetailRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId));
if (response.Success && response.Detail != null)
{
_detail = response.Detail;
}
else
{
_detailError = response.ErrorMessage ?? "Failed to load site call detail.";
}
}
catch (Exception ex)
{
_detailError = $"Failed to load site call detail: {ex.Message}";
}
_detailLoading = false;
}
private void CloseDetail()
{
_detailSiteCall = null;
_detail = null;
_detailError = null;
_detailLoading = false;
}
private async Task RetryFromDetail(SiteCallSummary c)
{
await RetrySiteCall(c);
// RefreshAll replaces the row list; close the modal so the user sees the
// refreshed grid rather than a now-stale detail snapshot.
CloseDetail();
}
private async Task DiscardFromDetail(SiteCallSummary c)
{
await DiscardSiteCall(c);
CloseDetail();
}
private void ClearFilters()
{
_statusFilter = string.Empty;
_channelFilter = string.Empty;
_siteFilter = string.Empty;
_targetFilter = string.Empty;
_stuckOnly = false;
_fromFilter = null;
_toFilter = null;
}
private bool HasActiveFilters =>
!string.IsNullOrEmpty(_statusFilter) ||
!string.IsNullOrEmpty(_channelFilter) ||
!string.IsNullOrEmpty(_siteFilter) ||
!string.IsNullOrEmpty(_targetFilter) ||
_stuckOnly ||
_fromFilter != null ||
_toFilter != null;
private string SiteName(string siteId) =>
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
/// <summary>
/// The filter inputs are UTC wall-clock — stamp <see cref="DateTimeKind.Utc"/>
/// on the local-typed value so the query is unambiguous.
/// </summary>
private static DateTime? ToUtc(DateTime? value) =>
value == null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
/// <summary>
/// The <c>SiteCalls</c> timestamps are UTC <see cref="DateTime"/>; wrap them as
/// a <see cref="DateTimeOffset"/> for <c>TimestampDisplay</c>.
/// </summary>
private static DateTimeOffset? AsOffset(DateTime? value) =>
value == null
? null
: new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc));
// A Guid's "N" format is always exactly 32 hex chars, so the [..12] slice is
// always in range — no length guard needed.
private static string ShortId(Guid id) => id.ToString("N")[..12];
private static string StatusBadgeClass(string status) => status switch
{
"Delivered" => "bg-success",
"Parked" => "bg-danger",
"Failed" => "bg-danger",
"Attempted" => "bg-warning text-dark",
"Forwarded" => "bg-info text-dark",
"Submitted" => "bg-info text-dark",
"Discarded" => "bg-secondary",
_ => "bg-light text-dark"
};
}
@@ -0,0 +1,40 @@
@typeparam TValue
@*
Compact multi-select control: a Bootstrap dropdown whose toggle button
summarises the current selection over a checkbox menu. Replaces a wrapped
block of chip buttons with a single control of one row's height.
*@
<div class="dropdown msd" data-test="@DataTest">
<button type="button"
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
disabled="@(Items.Count == 0)"
data-test="@($"{DataTest}-toggle")">
<span class="msd-summary">@Summary()</span>
</button>
<ul class="dropdown-menu msd-menu">
@if (Items.Count == 0)
{
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
}
else
{
@foreach (var item in Items)
{
var isSelected = Selected.Contains(item);
<li>
<label class="dropdown-item msd-item">
<input type="checkbox"
class="form-check-input msd-check"
checked="@isSelected"
@onchange="() => Toggle(item)"
data-test="@($"{DataTest}-opt-{item}")" />
<span>@Display(item)</span>
</label>
</li>
}
}
</ul>
</div>
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Components;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
/// summarises the current selection ("All" when empty, the single item's label
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
///
/// <para>
/// It exists to keep multi-value filter controls one row tall instead of a
/// wrapped block of chip buttons. The component mutates the caller-owned
/// <see cref="Selected"/> collection in place and raises
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
/// (re-render, prune dependent selections, …).
/// </para>
///
/// <para>
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
/// while the operator ticks several boxes.
/// </para>
/// </summary>
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
public partial class MultiSelectDropdown<TValue> where TValue : notnull
{
/// <summary>The options shown in the menu, in display order.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
/// <summary>
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
/// </summary>
[Parameter, EditorRequired]
public ICollection<TValue> Selected { get; set; } = default!;
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
[Parameter]
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
[Parameter]
public EventCallback SelectionChanged { get; set; }
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
[Parameter]
public string AllLabel { get; set; } = "All";
/// <summary>Text shown in the menu when there are no options.</summary>
[Parameter]
public string EmptyText { get; set; } = "None available";
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
[Parameter]
public string DataTest { get; set; } = "multi-select";
private async Task Toggle(TValue item)
{
// ICollection.Remove returns false when the item was absent — that is the
// "not currently selected" case, so add it. This is a plain toggle.
if (!Selected.Remove(item))
{
Selected.Add(item);
}
await SelectionChanged.InvokeAsync();
}
private string Summary()
{
var count = Selected.Count;
if (count == 0)
{
return AllLabel;
}
if (count == 1)
{
// Prefer the single selection's label over a bare "1 selected".
foreach (var item in Items)
{
if (Selected.Contains(item))
{
return Display(item);
}
}
// The one selected value is not in the current Items list (e.g. a Kind
// narrowed out by a Channel change before the parent pruned it).
return "1 selected";
}
return $"{count} selected";
}
}
@@ -0,0 +1,32 @@
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
form-control-sm controls in a filter row. */
.msd-toggle {
min-width: 9rem;
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep a long option list from running off-screen — scroll within the menu. */
.msd-menu {
max-height: 16rem;
overflow-y: auto;
}
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
menu stays open thanks to data-bs-auto-close="outside". */
.msd-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Neutralise the default form-check-input top margin so the box lines up with
the option text inside the dropdown-item. */
.msd-check {
flex: 0 0 auto;
margin: 0;
}
@@ -0,0 +1,190 @@
// Audit results grid column UX (#23 follow-ups Task 10).
//
// A tiny, dependency-free helper for the AuditResultsGrid component:
// - drag-to-resize: a pointer-driven handle on each <th>'s right edge,
// - drag-to-reorder: native HTML5 drag-and-drop on the header row,
// - save/load: a sessionStorage round-trip, mirroring treeview-storage.js.
//
// The Blazor component owns the column model; this file is purely the
// browser-side drag plumbing. After a resize or reorder it calls back into
// .NET via a DotNetObjectReference so the component can persist + re-render.
//
// No drag-drop libraries — hand-rolled pointer + native-DnD handlers only.
window.auditGrid = {
// --- sessionStorage wrapper (mirrors window.treeviewStorage) -----------
// Keys are namespaced under "auditGrid:" so they never collide with the
// treeview's "treeview:" namespace.
save: function (key, json) {
try {
sessionStorage.setItem("auditGrid:" + key, json);
} catch {
// Quota / privacy-mode failures are non-fatal — the grid simply
// falls back to defaults on the next load.
}
},
load: function (key) {
try {
return sessionStorage.getItem("auditGrid:" + key);
} catch {
return null;
}
},
// Minimum column width in pixels. A column can never be dragged narrower
// than this so a header can't collapse to an unclickable sliver.
minWidth: 64,
// --- wire-up ----------------------------------------------------------
// `table` is the <table> element, `dotNet` is a DotNetObjectReference
// exposing OnColumnResized / OnColumnReordered. Safe to call on every
// render: it re-scans the header and binds only cells not already bound,
// and always refreshes the live .NET reference. Handlers read the column
// key live from data-col-key at event time, so Blazor reusing a <th> DOM
// node for a different column (after a reorder re-render) is harmless.
init: function (table, dotNet) {
if (!table) {
return;
}
table.__auditGridDotNet = dotNet;
var headerRow = table.tHead && table.tHead.rows[0];
if (!headerRow) {
return;
}
for (var i = 0; i < headerRow.cells.length; i++) {
this._bindHeaderCell(table, headerRow.cells[i]);
}
},
// Bind resize + reorder handlers to a single <th>. Idempotent — a cell
// already carrying handlers is skipped. The handlers resolve the column
// key live (th.getAttribute) so they stay correct if the renderer reuses
// the element for another column.
_bindHeaderCell: function (table, th) {
var self = this;
if (th.__auditGridCellBound) {
return;
}
th.__auditGridCellBound = true;
// --- resize: pointer drag on the handle ---------------------------
var handle = th.querySelector(".audit-grid-resize-handle");
if (handle) {
handle.addEventListener("pointerdown", function (ev) {
ev.preventDefault();
// Stop the pointerdown from also starting a header drag.
ev.stopPropagation();
var startX = ev.clientX;
var startWidth = th.getBoundingClientRect().width;
handle.setPointerCapture(ev.pointerId);
th.classList.add("resizing");
function onMove(moveEv) {
var next = Math.max(self.minWidth, startWidth + (moveEv.clientX - startX));
self._applyWidth(th, next);
}
function onUp() {
handle.releasePointerCapture(ev.pointerId);
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
handle.removeEventListener("pointercancel", onUp);
th.classList.remove("resizing");
var key = th.getAttribute("data-col-key");
var finalWidth = Math.round(th.getBoundingClientRect().width);
var dn = table.__auditGridDotNet;
if (key && dn) {
dn.invokeMethodAsync("OnColumnResized", key, finalWidth);
}
}
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
handle.addEventListener("pointercancel", onUp);
});
}
// --- reorder: native HTML5 drag-and-drop on the header ------------
// The whole <th> is draggable; dropping it onto another header swaps
// the dragged column into the drop target's position.
th.setAttribute("draggable", "true");
th.addEventListener("dragstart", function (ev) {
// A resize in progress sets .resizing; never start a reorder then.
if (th.classList.contains("resizing")) {
ev.preventDefault();
return;
}
var key = th.getAttribute("data-col-key");
if (!key) {
ev.preventDefault();
return;
}
table.__auditGridDragKey = key;
ev.dataTransfer.effectAllowed = "move";
// Some browsers require data to be set for the drag to begin.
try { ev.dataTransfer.setData("text/plain", key); } catch { /* ignore */ }
th.classList.add("dragging");
});
th.addEventListener("dragend", function () {
th.classList.remove("dragging");
table.__auditGridDragKey = null;
self._clearDropTargets(table);
});
th.addEventListener("dragover", function (ev) {
// Allowing the drop is what lets dragover/drop fire at all.
var key = th.getAttribute("data-col-key");
if (key && table.__auditGridDragKey && table.__auditGridDragKey !== key) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
th.classList.add("drop-target");
}
});
th.addEventListener("dragleave", function () {
th.classList.remove("drop-target");
});
th.addEventListener("drop", function (ev) {
ev.preventDefault();
th.classList.remove("drop-target");
var key = th.getAttribute("data-col-key");
var fromKey = table.__auditGridDragKey;
table.__auditGridDragKey = null;
if (!key || !fromKey || fromKey === key) {
return;
}
var dn = table.__auditGridDotNet;
if (dn) {
// fromKey moves to occupy toKey's slot; the component computes
// the resulting order and re-renders + persists.
dn.invokeMethodAsync("OnColumnReordered", fromKey, key);
}
});
},
// Apply a width to a <th> via a CSS custom property. The scoped stylesheet
// reads --audit-col-width; absent it, the column falls back to auto.
//
// Known, intentional behaviour: during a live resize drag this updates the
// <th> width immediately, but the <td> body cells only catch up on the next
// .NET re-render (driven by OnColumnResized at pointer-up). The brief
// header/body width mismatch mid-drag is an accepted trade-off for an
// internal tool — not a bug.
_applyWidth: function (th, widthPx) {
th.style.setProperty("--audit-col-width", widthPx + "px");
},
_clearDropTargets: function (table) {
var hits = table.querySelectorAll(".drop-target, .dragging");
for (var i = 0; i < hits.length; i++) {
hits[i].classList.remove("drop-target", "dragging");
}
}
};
@@ -26,6 +26,13 @@ public sealed record AuditEvent
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary> /// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; } public Guid? CorrelationId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request — the universal
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
/// is the per-operation lifecycle id).
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary> /// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; } public string? SourceSiteId { get; init; }
@@ -27,6 +27,15 @@ public class Notification
public string SourceSiteId { get; set; } public string SourceSiteId { get; set; }
public string? SourceInstanceId { get; set; } public string? SourceInstanceId { get; set; }
public string? SourceScript { get; set; } public string? SourceScript { get; set; }
/// <summary>
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Carried from
/// the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/> so the
/// central dispatcher can stamp the same id onto its <c>NotifyDeliver</c> audit rows,
/// correlating them with the site-emitted <c>NotifySend</c> row. Null for notifications
/// submitted before the column existed, or raised outside a script-execution context.
/// </summary>
public Guid? OriginExecutionId { get; set; }
public DateTimeOffset SiteEnqueuedAt { get; set; } public DateTimeOffset SiteEnqueuedAt { get; set; }
/// <summary>Central ingest time.</summary> /// <summary>Central ingest time.</summary>
@@ -63,4 +63,27 @@ public interface ISiteCallAuditRepository
/// deleted. /// deleted.
/// </summary> /// </summary>
Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default); Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default);
/// <summary>
/// Computes a point-in-time global <see cref="SiteCallKpiSnapshot"/> from the
/// <c>SiteCalls</c> table. Counts are aggregated server-side (no row
/// materialisation): <c>StuckCount</c> uses <paramref name="stuckCutoff"/>;
/// <c>FailedLastInterval</c> / <c>DeliveredLastInterval</c> use
/// <paramref name="intervalSince"/>; the current time for <c>OldestPendingAge</c>
/// is captured inside the method.
/// </summary>
Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff,
DateTime intervalSince,
CancellationToken ct = default);
/// <summary>
/// Computes a point-in-time <see cref="SiteCallSiteKpiSnapshot"/> per source
/// site. Sites with no <c>SiteCalls</c> rows at all are omitted. The stuck
/// cutoff and interval bounds are interpreted as in <see cref="ComputeKpisAsync"/>.
/// </summary>
Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff,
DateTime intervalSince,
CancellationToken ct = default);
} }
@@ -57,6 +57,20 @@ public interface ICachedCallLifecycleObserver
/// <param name="OccurredAtUtc">When this attempt completed.</param> /// <param name="OccurredAtUtc">When this attempt completed.</param>
/// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param> /// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param>
/// <param name="SourceInstanceId">Originating instance, when known.</param> /// <param name="SourceInstanceId">Originating instance, when known.</param>
/// <param name="ExecutionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id, threaded through the store-and-forward buffer from
/// the cached-call enqueue path. The audit bridge stamps it onto the
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
/// <c>CachedResolve</c> rows so they correlate with the rest of the run.
/// <c>null</c> for rows buffered before Task 4 (back-compat).
/// </param>
/// <param name="SourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded alongside <paramref name="ExecutionId"/> so the retry-loop audit
/// rows carry the same <c>SourceScript</c> provenance the script-side cached
/// rows already do. <c>null</c> when not known.
/// </param>
public sealed record CachedCallAttemptContext( public sealed record CachedCallAttemptContext(
TrackedOperationId TrackedOperationId, TrackedOperationId TrackedOperationId,
string Channel, string Channel,
@@ -69,7 +83,9 @@ public sealed record CachedCallAttemptContext(
DateTime CreatedAtUtc, DateTime CreatedAtUtc,
DateTime OccurredAtUtc, DateTime OccurredAtUtc,
int? DurationMs, int? DurationMs,
string? SourceInstanceId); string? SourceInstanceId,
Guid? ExecutionId = null,
string? SourceScript = null);
/// <summary> /// <summary>
/// Coarse outcome of one cached-call delivery attempt, observed from inside /// Coarse outcome of one cached-call delivery attempt, observed from inside
@@ -29,11 +29,24 @@ public interface IDatabaseGateway
/// <c>null</c> — when omitted the S&amp;F engine mints a fresh GUID and no /// <c>null</c> — when omitted the S&amp;F engine mints a fresh GUID and no
/// M3 telemetry is correlated (pre-M3 caller behaviour). /// M3 telemetry is correlated (pre-M3 caller behaviour).
/// </param> /// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. When the write is buffered on a transient
/// failure this is threaded onto the S&amp;F message so the retry-loop
/// cached-write audit rows carry it. <c>null</c> when not threaded.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered S&amp;F message alongside
/// <paramref name="executionId"/>. <c>null</c> when not known.
/// </param>
Task CachedWriteAsync( Task CachedWriteAsync(
string connectionName, string connectionName,
string sql, string sql,
IReadOnlyDictionary<string, object?>? parameters = null, IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null, string? originInstanceName = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null); TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null);
} }
@@ -30,13 +30,26 @@ public interface IExternalSystemClient
/// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely /// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
/// on). /// on).
/// </param> /// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. When the call is buffered on a transient
/// failure this is threaded onto the S&amp;F message so the retry-loop
/// cached-call audit rows carry it. <c>null</c> when not threaded.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered S&amp;F message alongside
/// <paramref name="executionId"/>. <c>null</c> when not known.
/// </param>
Task<ExternalCallResult> CachedCallAsync( Task<ExternalCallResult> CachedCallAsync(
string systemName, string systemName,
string methodName, string methodName,
IReadOnlyDictionary<string, object?>? parameters = null, IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null, string? originInstanceName = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null); TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null);
} }
/// <summary> /// <summary>
@@ -0,0 +1,163 @@
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.Commons.Messages.Audit;
/// <summary>
/// Site Calls UI -> Central: paginated, filtered query over the central
/// <c>SiteCalls</c> table (Site Call Audit #22). All filter fields are optional;
/// <see cref="StuckOnly"/> restricts results to stuck cached calls. Mirrors
/// <see cref="ScadaLink.Commons.Messages.Notification.NotificationOutboxQueryRequest"/>
/// but uses keyset paging (<see cref="AfterCreatedAtUtc"/> + <see cref="AfterId"/>)
/// to match the repository's <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c>
/// cursor, rather than page numbers.
/// </summary>
/// <remarks>
/// <see cref="ChannelFilter"/> matches the <c>SiteCall.Channel</c> column —
/// <c>"ApiOutbound"</c> or <c>"DbOutbound"</c> (the spec's <c>Kind</c> notion;
/// the entity exposes it as <c>Channel</c>). <see cref="TargetKeyword"/> is an
/// exact-match target filter, consistent with the repository's
/// <see cref="SiteCallQueryFilter.Target"/> predicate.
/// </remarks>
/// <param name="PageSize">
/// Requested page size. The actor clamps this to the <c>[1, 200]</c> range, so
/// the effective ceiling is 200 rows per page regardless of the value sent.
/// </param>
public sealed record SiteCallQueryRequest(
string CorrelationId,
string? StatusFilter,
string? SourceSiteFilter,
string? ChannelFilter,
string? TargetKeyword,
bool StuckOnly,
DateTime? FromUtc,
DateTime? ToUtc,
DateTime? AfterCreatedAtUtc,
Guid? AfterId,
int PageSize);
/// <summary>
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
/// only the columns the <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/>
/// entity genuinely exposes — there are no source-instance/script provenance
/// columns on that entity, so unlike
/// <see cref="ScadaLink.Commons.Messages.Notification.NotificationSummary"/>
/// none are surfaced here.
/// </summary>
/// <remarks>
/// <see cref="HttpStatus"/> is not called out in the Site Call Audit plan, but
/// it is a real (nullable) <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/>
/// column — the last HTTP status code observed for the call — so it is surfaced
/// here for the grid; <c>null</c> for non-HTTP channels or before a first attempt.
/// </remarks>
public sealed record SiteCallSummary(
Guid TrackedOperationId,
string SourceSite,
string Channel,
string Target,
string Status,
int RetryCount,
string? LastError,
int? HttpStatus,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? TerminalAtUtc,
bool IsStuck);
/// <summary>
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
/// The keyset cursor of the last row is echoed back as
/// <see cref="NextAfterCreatedAtUtc"/> + <see cref="NextAfterId"/> for the caller
/// to request the following page; both are <c>null</c> when the page was empty.
/// On a repository fault <see cref="Success"/> is <c>false</c>,
/// <see cref="ErrorMessage"/> carries the cause and <see cref="SiteCalls"/> is empty.
/// </summary>
public sealed record SiteCallQueryResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
IReadOnlyList<SiteCallSummary> SiteCalls,
DateTime? NextAfterCreatedAtUtc,
Guid? NextAfterId);
/// <summary>
/// Site Calls UI -> Central: request for the full detail of a single cached call,
/// for the report detail modal.
/// </summary>
public sealed record SiteCallDetailRequest(
string CorrelationId,
Guid TrackedOperationId);
/// <summary>
/// Central -> Site Calls UI: full detail for one cached call. On a repository
/// fault or missing row, <see cref="Success"/> is <c>false</c> /
/// <see cref="Detail"/> is <c>null</c> and <see cref="ErrorMessage"/> carries
/// the cause.
/// </summary>
public sealed record SiteCallDetailResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
SiteCallDetail? Detail);
/// <summary>
/// Full <c>SiteCalls</c> row detail for the report detail modal — every field
/// on the <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> entity,
/// including <see cref="LastError"/> and the <see cref="IngestedAtUtc"/>
/// timestamp the grid summary omits.
/// </summary>
public sealed record SiteCallDetail(
Guid TrackedOperationId,
string SourceSite,
string Channel,
string Target,
string Status,
int RetryCount,
string? LastError,
int? HttpStatus,
DateTime CreatedAtUtc,
DateTime UpdatedAtUtc,
DateTime? TerminalAtUtc,
DateTime IngestedAtUtc);
/// <summary>
/// Site Calls UI -> Central: request for the global <c>SiteCalls</c> KPI summary.
/// Mirrors <see cref="ScadaLink.Commons.Messages.Notification.NotificationKpiRequest"/>.
/// </summary>
public sealed record SiteCallKpiRequest(
string CorrelationId);
/// <summary>
/// Central -> Site Calls UI: KPI summary for the Site Calls dashboard. On a
/// repository fault <see cref="Success"/> is <c>false</c>,
/// <see cref="ErrorMessage"/> carries the cause, and the KPI fields are
/// zeroed/<c>null</c>.
/// </summary>
public sealed record SiteCallKpiResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
int BufferedCount,
int ParkedCount,
int FailedLastInterval,
int DeliveredLastInterval,
TimeSpan? OldestPendingAge,
int StuckCount);
/// <summary>
/// Site Calls UI -> Central: request for the per-source-site <c>SiteCalls</c>
/// KPI breakdown. Mirrors
/// <see cref="ScadaLink.Commons.Messages.Notification.PerSiteNotificationKpiRequest"/>.
/// </summary>
public sealed record PerSiteSiteCallKpiRequest(
string CorrelationId);
/// <summary>
/// Central -> Site Calls UI: per-site KPI breakdown for the Site Calls KPIs
/// page. On a repository fault <see cref="Success"/> is <c>false</c>,
/// <see cref="ErrorMessage"/> carries the cause, and <see cref="Sites"/> is empty.
/// </summary>
public sealed record PerSiteSiteCallKpiResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
IReadOnlyList<SiteCallSiteKpiSnapshot> Sites);
@@ -0,0 +1,113 @@
namespace ScadaLink.Commons.Messages.Audit;
/// <summary>
/// Outcome of a Site Call Audit (#22) Retry/Discard relay — distinguishes the
/// three cases the Central UI Site Calls page must surface differently.
/// </summary>
/// <remarks>
/// The "site unreachable" case is deliberately separate from
/// <see cref="OperationFailed"/>: central is an eventually-consistent mirror,
/// not the source of truth, so a relay that never reaches the owning site is a
/// transient transport condition the operator can retry — not a failed
/// operation. The UI shows "site unreachable" rather than a generic error.
/// </remarks>
public enum SiteCallRelayOutcome
{
/// <summary>
/// The owning site received the relay command and applied the action to its
/// Store-and-Forward buffer (the parked cached call was reset to retry, or
/// discarded). The corrected state reaches central later via telemetry.
/// </summary>
Applied,
/// <summary>
/// The owning site received the relay command but found nothing to do — no
/// parked row matched the tracked id (already delivered/discarded, or no
/// longer <c>Parked</c>). A definitive answer from the site, not a failure.
/// </summary>
NotParked,
/// <summary>
/// The owning site could not be reached (offline / no ClusterClient route /
/// relay timed out). The action was NOT applied; the operator may retry once
/// the site is back online.
/// </summary>
SiteUnreachable,
/// <summary>
/// The owning site was reached but reported it could not apply the action
/// (its parked-message handler was unavailable or its store faulted).
/// </summary>
OperationFailed,
}
/// <summary>
/// Central UI → Site Call Audit: relay a Retry of a parked cached call to its
/// owning site. The owning site performs the actual retry on its
/// Store-and-Forward buffer — central never mutates the central <c>SiteCalls</c>
/// mirror row. Mirrors
/// <see cref="ScadaLink.Commons.Messages.Notification.RetryNotificationRequest"/>
/// but carries <see cref="SourceSite"/> (the relay target) and answers with a
/// distinct site-unreachable outcome.
/// </summary>
/// <param name="CorrelationId">Request correlation id, echoed on the response.</param>
/// <param name="TrackedOperationId">
/// The cached operation to retry — the PK of the central <c>SiteCalls</c> row
/// and the S&amp;F buffer message id at the owning site.
/// </param>
/// <param name="SourceSite">
/// The owning site (<c>SiteCall.SourceSite</c>) the relay is routed to.
/// </param>
public sealed record RetrySiteCallRequest(
string CorrelationId,
Guid TrackedOperationId,
string SourceSite);
/// <summary>
/// Site Call Audit → Central UI: result of a <see cref="RetrySiteCallRequest"/>.
/// </summary>
/// <param name="CorrelationId">Echoed request correlation id.</param>
/// <param name="Outcome">
/// The relay outcome — <see cref="SiteCallRelayOutcome.Applied"/>,
/// <see cref="SiteCallRelayOutcome.NotParked"/>,
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> or
/// <see cref="SiteCallRelayOutcome.OperationFailed"/>.
/// </param>
/// <param name="Success">
/// Convenience flag — <c>true</c> only for <see cref="SiteCallRelayOutcome.Applied"/>.
/// </param>
/// <param name="SiteReachable">
/// <c>false</c> only for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>; lets
/// the UI distinguish "site offline" from "operation failed" without switching
/// on the enum.
/// </param>
/// <param name="ErrorMessage">
/// Human-readable detail for a non-applied outcome; <c>null</c> on success.
/// </param>
public sealed record RetrySiteCallResponse(
string CorrelationId,
SiteCallRelayOutcome Outcome,
bool Success,
bool SiteReachable,
string? ErrorMessage);
/// <summary>
/// Central UI → Site Call Audit: relay a Discard of a parked cached call to its
/// owning site. See <see cref="RetrySiteCallRequest"/> for the source-of-truth
/// and routing rationale.
/// </summary>
public sealed record DiscardSiteCallRequest(
string CorrelationId,
Guid TrackedOperationId,
string SourceSite);
/// <summary>
/// Site Call Audit → Central UI: result of a <see cref="DiscardSiteCallRequest"/>.
/// Same shape as <see cref="RetrySiteCallResponse"/>.
/// </summary>
public sealed record DiscardSiteCallResponse(
string CorrelationId,
SiteCallRelayOutcome Outcome,
bool Success,
bool SiteReachable,
string? ErrorMessage);
@@ -6,4 +6,4 @@ public record CreateNotificationListCommand(string Name, IReadOnlyList<string> R
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails); public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails);
public record DeleteNotificationListCommand(int NotificationListId); public record DeleteNotificationListCommand(int NotificationListId);
public record ListSmtpConfigsCommand; public record ListSmtpConfigsCommand;
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress); public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null);
@@ -4,6 +4,13 @@ namespace ScadaLink.Commons.Messages.Notification;
/// Site -> Central: submit a notification for central delivery. /// Site -> Central: submit a notification for central delivery.
/// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received. /// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received.
/// </summary> /// </summary>
/// <param name="OriginExecutionId">
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Stamped at
/// <c>Notify.Send</c> time and carried, inside the serialized payload, through the site
/// store-and-forward buffer so the central dispatcher can echo it onto the
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
/// before the field existed, or for notifications raised outside a script execution.
/// </param>
public record NotificationSubmit( public record NotificationSubmit(
string NotificationId, string NotificationId,
string ListName, string ListName,
@@ -12,7 +19,8 @@ public record NotificationSubmit(
string SourceSiteId, string SourceSiteId,
string? SourceInstanceId, string? SourceInstanceId,
string? SourceScript, string? SourceScript,
DateTimeOffset SiteEnqueuedAt); DateTimeOffset SiteEnqueuedAt,
Guid? OriginExecutionId = null);
/// <summary> /// <summary>
/// Central -> Site: ack sent after the notification row is persisted. /// Central -> Site: ack sent after the notification row is persisted.
@@ -76,6 +76,49 @@ public record DiscardNotificationResponse(
bool Success, bool Success,
string? ErrorMessage); string? ErrorMessage);
/// <summary>
/// Outbox UI -> Central: request for the full detail of a single notification
/// (including Body and resolved recipients), for the report detail modal.
/// </summary>
public record NotificationDetailRequest(
string CorrelationId,
string NotificationId);
/// <summary>
/// Central -> Outbox UI: full detail for one notification. On a repository fault or
/// missing row, Success is false / Detail is null and ErrorMessage carries the cause.
/// </summary>
public record NotificationDetailResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
NotificationDetail? Detail);
/// <summary>
/// Full notification detail for the report detail modal — everything in the grid's
/// NotificationSummary plus Body, ResolvedTargets (recipients), TypeData, SourceScript,
/// and the additional lifecycle timestamps.
/// </summary>
public record NotificationDetail(
string NotificationId,
string Type,
string ListName,
string Subject,
string Body,
string Status,
int RetryCount,
string? LastError,
string? ResolvedTargets,
string? TypeData,
string SourceSiteId,
string? SourceInstanceId,
string? SourceScript,
DateTimeOffset SiteEnqueuedAt,
DateTimeOffset CreatedAt,
DateTimeOffset? LastAttemptAt,
DateTimeOffset? NextAttemptAt,
DateTimeOffset? DeliveredAt);
/// <summary> /// <summary>
/// Outbox UI -> Central: request for the notification outbox KPI summary. /// Outbox UI -> Central: request for the notification outbox KPI summary.
/// </summary> /// </summary>
@@ -0,0 +1,75 @@
using ScadaLink.Commons.Types;
namespace ScadaLink.Commons.Messages.RemoteQuery;
/// <summary>
/// Central → site relay command: retry a parked cached operation
/// (<c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c>) on the
/// owning site's Store-and-Forward buffer. Sent over the command/control
/// channel by <c>SiteCallAuditActor</c> when an operator clicks Retry on a
/// <c>Parked</c> Site Call row in the Central UI.
/// </summary>
/// <remarks>
/// <para>
/// The site is the source of truth for cached-call status — central never
/// mutates the central <c>SiteCalls</c> mirror row directly. This command asks
/// the site to reset its own parked row back to <c>Pending</c> so the S&amp;F
/// retry sweep attempts delivery again; the corrected state then flows back to
/// central via the normal cached-call telemetry path.
/// </para>
/// <para>
/// The cached call's S&amp;F buffer message id is the
/// <see cref="TrackedOperationId"/> itself (the tracked id is supplied as the
/// buffered row's id at enqueue time), so the site can resolve the parked row
/// directly from <see cref="TrackedOperationId"/>. A retry on a row that is not
/// actually <c>Parked</c> is a safe no-op at the site — the ack reports
/// <c>Applied=false</c> rather than corrupting a non-parked row.
/// </para>
/// <para>
/// This is a plain record carrying only ids, so it lives in Commons (no
/// <c>IActorRef</c> field). It mirrors <see cref="ParkedMessageRetryRequest"/>
/// but keys on <see cref="TrackedOperationId"/> rather than the opaque S&amp;F
/// message-id string.
/// </para>
/// </remarks>
public sealed record RetryParkedOperation(
string CorrelationId,
TrackedOperationId TrackedOperationId);
/// <summary>
/// Central → site relay command: discard a parked cached operation on the
/// owning site's Store-and-Forward buffer. Sent over the command/control
/// channel by <c>SiteCallAuditActor</c> when an operator clicks Discard on a
/// <c>Parked</c> Site Call row in the Central UI. See
/// <see cref="RetryParkedOperation"/> for the source-of-truth and message-id
/// rationale; Discard marks the operation terminally <c>Discarded</c> at the
/// site by removing the parked S&amp;F buffer row.
/// </summary>
public sealed record DiscardParkedOperation(
string CorrelationId,
TrackedOperationId TrackedOperationId);
/// <summary>
/// Site → central ack for a <see cref="RetryParkedOperation"/> /
/// <see cref="DiscardParkedOperation"/> relay command. The site replies this
/// after applying (or safely no-op-ing) the action against its own
/// Store-and-Forward buffer.
/// </summary>
/// <param name="CorrelationId">Correlation id of the originating relay command.</param>
/// <param name="Applied">
/// <c>true</c> when the parked operation was found and the action was applied;
/// <c>false</c> when no parked row matched the <see cref="RetryParkedOperation.TrackedOperationId"/>
/// (already delivered, discarded, never cached, or not in a <c>Parked</c>
/// state). A <c>false</c> ack is a definitive "nothing to do" answer from the
/// site — it is NOT a transport failure, so the relay must distinguish it from
/// a site-unreachable timeout.
/// </param>
/// <param name="ErrorMessage">
/// Populated only when the site could not apply the action (e.g. the parked
/// message handler is not available, or the S&amp;F store faulted); <c>null</c>
/// on a clean <c>Applied=true</c>/<c>Applied=false</c> outcome.
/// </param>
public sealed record ParkedOperationActionAck(
string CorrelationId,
bool Applied,
string? ErrorMessage = null);
@@ -4,18 +4,25 @@ namespace ScadaLink.Commons.Types.Audit;
/// <summary> /// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>. /// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds /// Any field left <c>null</c> means "do not constrain on that column". The
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and /// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses /// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
/// <c>&gt;=</c> / <c>&lt;=</c> respectively. All filter fields are AND-combined. /// list means "do not constrain", and a non-empty list is OR-combined within the
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another. The
/// single-value <see cref="CorrelationId"/> and <see cref="ExecutionId"/>
/// dimensions constrain on equality when set.
/// </summary> /// </summary>
public sealed record AuditLogQueryFilter( public sealed record AuditLogQueryFilter(
AuditChannel? Channel = null, IReadOnlyList<AuditChannel>? Channels = null,
AuditKind? Kind = null, IReadOnlyList<AuditKind>? Kinds = null,
AuditStatus? Status = null, IReadOnlyList<AuditStatus>? Statuses = null,
string? SourceSiteId = null, IReadOnlyList<string>? SourceSiteIds = null,
string? Target = null, string? Target = null,
string? Actor = null, string? Actor = null,
Guid? CorrelationId = null, Guid? CorrelationId = null,
Guid? ExecutionId = null,
DateTime? FromUtc = null, DateTime? FromUtc = null,
DateTime? ToUtc = null); DateTime? ToUtc = null);
@@ -0,0 +1,79 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Shared lax parsers for the multi-value Audit Log query parameters
/// (<c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c>). The Audit Log filter
/// wire-contract is consumed by three surfaces that MUST stay in lockstep:
/// <list type="bullet">
/// <item>the ManagementService <c>/api/audit/query</c> + <c>/api/audit/export</c>
/// endpoints,</item>
/// <item>the CentralUI <c>/api/centralui/audit/export</c> endpoint, and</item>
/// <item>the CentralUI <c>AuditLogPage</c> query-string drill-in parser.</item>
/// </list>
///
/// <para>
/// Each caller extracts the raw repeated values for a single parameter from its
/// own request type (ASP.NET <c>IQueryCollection</c>, a
/// <c>Dictionary&lt;string, StringValues&gt;</c> from <c>QueryHelpers.ParseQuery</c>,
/// etc.) and passes them here as a plain <see cref="IEnumerable{T}"/> of strings —
/// so this helper carries NO ASP.NET / <c>Microsoft.Extensions.Primitives</c>
/// dependency and can live in <c>ScadaLink.Commons</c>.
/// </para>
///
/// <para>
/// <b>Lax-parse contract.</b> Every value of a repeated parameter is parsed
/// independently; an unparseable or blank element is silently dropped (NO 400)
/// rather than failing the whole set. An empty result collapses to <c>null</c> so
/// the corresponding filter dimension stays unconstrained.
/// </para>
/// </summary>
public static class AuditQueryParamParsers
{
/// <summary>
/// Parses each raw value as <typeparamref name="TEnum"/> (case-insensitive),
/// dropping unparseable values silently. Returns <c>null</c> when
/// <paramref name="rawValues"/> is <c>null</c>, empty, or yields no parseable
/// value — so the filter dimension stays unconstrained.
/// </summary>
public static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IEnumerable<string?>? rawValues)
where TEnum : struct, Enum
{
if (rawValues is null)
{
return null;
}
var parsed = new List<TEnum>();
foreach (var raw in rawValues)
{
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
{
parsed.Add(value);
}
}
return parsed.Count > 0 ? parsed : null;
}
/// <summary>
/// Trims each raw value and drops blank entries. Returns <c>null</c> when
/// <paramref name="rawValues"/> is <c>null</c>, empty, or every value was
/// blank.
/// </summary>
public static IReadOnlyList<string>? ParseStringList(IEnumerable<string?>? rawValues)
{
if (rawValues is null)
{
return null;
}
var parsed = new List<string>();
foreach (var raw in rawValues)
{
if (!string.IsNullOrWhiteSpace(raw))
{
parsed.Add(raw.Trim());
}
}
return parsed.Count > 0 ? parsed : null;
}
}
@@ -0,0 +1,38 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Point-in-time operational metrics for the central <c>SiteCalls</c> table
/// (Site Call Audit #22), surfaced on the health dashboard. The cached-call
/// counterpart of <see cref="ScadaLink.Commons.Types.Notifications.NotificationKpiSnapshot"/>;
/// mirrors its shape so the Central UI Site Calls KPI tiles can reuse the
/// Notification Outbox tile layout.
/// </summary>
/// <param name="BufferedCount">
/// Count of non-terminal rows (<c>TerminalAtUtc IS NULL</c>) — calls
/// buffered at sites awaiting retry.
/// </param>
/// <param name="ParkedCount">Count of rows in the <c>Parked</c> status.</param>
/// <param name="FailedLastInterval">
/// Count of <c>Failed</c> rows whose <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.TerminalAtUtc"/>
/// is at or after the supplied "since" timestamp.
/// </param>
/// <param name="DeliveredLastInterval">
/// Count of <c>Delivered</c> rows whose <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.TerminalAtUtc"/>
/// is at or after the supplied "since" timestamp.
/// </param>
/// <param name="OldestPendingAge">
/// Age of the oldest non-terminal row (<c>now - min(CreatedAtUtc)</c>), or
/// <c>null</c> when there are no non-terminal rows.
/// </param>
/// <param name="StuckCount">
/// Count of non-terminal rows (<c>TerminalAtUtc IS NULL</c>) whose
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.CreatedAtUtc"/> is older
/// than the supplied stuck cutoff. Display-only — no escalation.
/// </param>
public sealed record SiteCallKpiSnapshot(
int BufferedCount,
int ParkedCount,
int FailedLastInterval,
int DeliveredLastInterval,
TimeSpan? OldestPendingAge,
int StuckCount);
@@ -12,10 +12,25 @@ namespace ScadaLink.Commons.Types.Audit;
/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls /// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
/// page exposes them as drop-down filters, not free-text search. /// page exposes them as drop-down filters, not free-text search.
/// </remarks> /// </remarks>
/// <param name="Channel">Restrict to a single channel (exact match).</param>
/// <param name="SourceSite">Restrict to a single source site (exact match).</param>
/// <param name="Status">Restrict to a single status (exact match).</param>
/// <param name="Target">Restrict to a single target (exact match).</param>
/// <param name="FromUtc">Inclusive lower bound on <c>CreatedAtUtc</c>.</param>
/// <param name="ToUtc">Inclusive upper bound on <c>CreatedAtUtc</c>.</param>
/// <param name="StuckCutoffUtc">
/// When set, restrict to stuck rows: <c>TerminalAtUtc IS NULL AND CreatedAtUtc &lt;
/// StuckCutoffUtc</c>. Both columns are plain (no value converter) and compose
/// directly with the keyset cursor. Mirrors
/// <see cref="ScadaLink.Commons.Types.Notifications.NotificationOutboxFilter.StuckCutoff"/>;
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
/// pages with a non-null next cursor.
/// </param>
public sealed record SiteCallQueryFilter( public sealed record SiteCallQueryFilter(
string? Channel = null, string? Channel = null,
string? SourceSite = null, string? SourceSite = null,
string? Status = null, string? Status = null,
string? Target = null, string? Target = null,
DateTime? FromUtc = null, DateTime? FromUtc = null,
DateTime? ToUtc = null); DateTime? ToUtc = null,
DateTime? StuckCutoffUtc = null);
@@ -0,0 +1,34 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Point-in-time <c>SiteCalls</c> metrics scoped to a single source site. The
/// per-site counterpart of <see cref="SiteCallKpiSnapshot"/>; surfaced in the
/// per-site breakdown table on the Site Calls KPIs page. Mirrors
/// <see cref="ScadaLink.Commons.Types.Notifications.SiteNotificationKpiSnapshot"/>.
/// </summary>
/// <param name="SourceSite">The site identifier these metrics are scoped to.</param>
/// <param name="BufferedCount">Count of this site's non-terminal rows (<c>TerminalAtUtc IS NULL</c>).</param>
/// <param name="ParkedCount">Count of this site's rows in the <c>Parked</c> status.</param>
/// <param name="FailedLastInterval">
/// Count of this site's <c>Failed</c> rows whose <c>TerminalAtUtc</c> is at or
/// after the "since" timestamp.
/// </param>
/// <param name="DeliveredLastInterval">
/// Count of this site's <c>Delivered</c> rows whose <c>TerminalAtUtc</c> is at
/// or after the "since" timestamp.
/// </param>
/// <param name="OldestPendingAge">
/// Age of this site's oldest non-terminal row, or <c>null</c> when it has none.
/// </param>
/// <param name="StuckCount">
/// Count of this site's non-terminal rows whose <c>CreatedAtUtc</c> is older
/// than the stuck cutoff.
/// </param>
public sealed record SiteCallSiteKpiSnapshot(
string SourceSite,
int BufferedCount,
int ParkedCount,
int FailedLastInterval,
int DeliveredLastInterval,
TimeSpan? OldestPendingAge,
int StuckCount);
@@ -5,6 +5,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event; using Akka.Event;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Messages.Communication; using ScadaLink.Commons.Messages.Communication;
using ScadaLink.Commons.Messages.Health; using ScadaLink.Commons.Messages.Health;
using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Messages.Notification;
@@ -76,6 +77,43 @@ public class CentralCommunicationActor : ReceiveActor
/// </summary> /// </summary>
private IActorRef? _notificationOutboxProxy; private IActorRef? _notificationOutboxProxy;
/// <summary>
/// Proxy <see cref="IActorRef"/> for the central AuditLogIngestActor cluster
/// singleton. Set via <see cref="RegisterAuditIngest"/> — the Host creates the
/// singleton proxy after this actor and registers it (mirrors
/// <see cref="_notificationOutboxProxy"/>). Null until registration completes;
/// an audit ingest command arriving before then is answered with an empty
/// reply so the site keeps its rows Pending and retries.
///
/// Once registered, the handler Asks this proxy and pipes the reply straight
/// back to the caller. On an Ask timeout or a faulted reply, PipeTo forwards a
/// <see cref="Status.Failure"/> to the caller — the fault propagates rather
/// than being swallowed. This differs from the gRPC handler
/// (<c>SiteStreamGrpcServer</c>), which catches the exception and returns an
/// empty ack; here the faulted Ask is the transient signal the site relies on
/// (see <see cref="HandleIngestAuditEvents"/>).
/// </summary>
private IActorRef? _auditIngestProxy;
/// <summary>
/// Default Ask timeout for routing audit ingest commands to the
/// AuditLogIngestActor proxy — 30 s, matching the value of
/// <c>SiteStreamGrpcServer.AuditIngestAskTimeout</c> (that constant is private
/// to the gRPC server and not reachable here, so it is declared locally). A
/// generous window absorbs a slow MS SQL connection without the round-trip
/// surfacing as a failure on a healthy site. When the window is exceeded the
/// Ask faults and that fault is piped back to the caller as a
/// <see cref="Status.Failure"/> (see <see cref="HandleIngestAuditEvents"/>).
/// </summary>
private static readonly TimeSpan DefaultAuditIngestAskTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Effective Ask timeout for audit ingest routing. Defaults to
/// <see cref="DefaultAuditIngestAskTimeout"/>; overridable via the constructor
/// so tests can exercise the timeout/fault path without waiting 30 s.
/// </summary>
private readonly TimeSpan _auditIngestAskTimeout;
/// <summary> /// <summary>
/// DistributedPubSub topic used to fan health reports out to the peer /// DistributedPubSub topic used to fan health reports out to the peer
/// central node so both per-node aggregators stay in sync. See /// central node so both per-node aggregators stay in sync. See
@@ -83,10 +121,19 @@ public class CentralCommunicationActor : ReceiveActor
/// </summary> /// </summary>
private const string HealthReportTopic = "site-health-replica"; private const string HealthReportTopic = "site-health-replica";
public CentralCommunicationActor(IServiceProvider serviceProvider, ISiteClientFactory siteClientFactory) /// <param name="auditIngestAskTimeout">
/// Optional override for the audit-ingest Ask timeout; defaults to
/// <see cref="DefaultAuditIngestAskTimeout"/> (30 s). Exists only so tests can
/// exercise the timeout/fault path quickly — production always uses the default.
/// </param>
public CentralCommunicationActor(
IServiceProvider serviceProvider,
ISiteClientFactory siteClientFactory,
TimeSpan? auditIngestAskTimeout = null)
{ {
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_siteClientFactory = siteClientFactory; _siteClientFactory = siteClientFactory;
_auditIngestAskTimeout = auditIngestAskTimeout ?? DefaultAuditIngestAskTimeout;
// Site address cache loaded from database // Site address cache loaded from database
Receive<SiteAddressCacheLoaded>(HandleSiteAddressCacheLoaded); Receive<SiteAddressCacheLoaded>(HandleSiteAddressCacheLoaded);
@@ -133,6 +180,24 @@ public class CentralCommunicationActor : ReceiveActor
// so the NotificationStatusResponse routes back to the querying site. // so the NotificationStatusResponse routes back to the querying site.
Receive<NotificationStatusQuery>(HandleNotificationStatusQuery); Receive<NotificationStatusQuery>(HandleNotificationStatusQuery);
// Audit Log (#23): the Host registers the AuditLogIngestActor singleton
// proxy after this actor is created (the proxy cannot exist before this
// actor's construction).
Receive<RegisterAuditIngest>(msg =>
{
_auditIngestProxy = msg.AuditIngestActor;
_log.Info("Registered audit ingest proxy");
});
// Audit Log (#23) site→central ingest: a site forwards a batch of audit
// events to the central cluster via ClusterClient. Ask the ingest proxy
// and pipe the IngestAuditEventsReply back to the original Sender (the
// site's ClusterClient path) so the site can flip its rows to Forwarded.
Receive<IngestAuditEventsCommand>(HandleIngestAuditEvents);
// Audit Log (#23 M3) combined-telemetry ingest: routes to the same proxy
// the same way; the proxy replies with an IngestCachedTelemetryReply.
Receive<IngestCachedTelemetryCommand>(HandleIngestCachedTelemetry);
} }
private void HandleNotificationSubmit(NotificationSubmit msg) private void HandleNotificationSubmit(NotificationSubmit msg)
@@ -172,6 +237,51 @@ public class CentralCommunicationActor : ReceiveActor
_notificationOutboxProxy.Forward(msg); _notificationOutboxProxy.Forward(msg);
} }
private void HandleIngestAuditEvents(IngestAuditEventsCommand msg)
{
if (_auditIngestProxy == null)
{
// No ingest proxy registered yet (host startup race). Reply with an
// empty IngestAuditEventsReply so the site keeps its rows Pending and
// retries — the same behaviour as the gRPC handler's wiring-race path.
_log.Warning(
"Cannot route IngestAuditEventsCommand ({0} events) — audit ingest not available",
msg.Events.Count);
Sender.Tell(new IngestAuditEventsReply(Array.Empty<Guid>()));
return;
}
// Capture Sender before the async/PipeTo — Akka resets Sender between
// dispatches. The reply is piped straight back to the site's ClusterClient.
// On an Ask timeout or a faulted reply, PipeTo delivers a Status.Failure to
// replyTo: the fault propagates to the caller rather than being swallowed.
// The site's own Ask through this path then faults, and the site drain loop
// treats that as a transient failure — rows stay Pending and are retried on
// the next tick. (The gRPC handler instead returns an empty ack on fault;
// propagating the fault here is the cleaner transient signal.)
var replyTo = Sender;
_log.Debug("Routing IngestAuditEventsCommand ({0} events) to the audit ingest actor", msg.Events.Count);
_auditIngestProxy.Ask<IngestAuditEventsReply>(msg, _auditIngestAskTimeout)
.PipeTo(replyTo);
}
private void HandleIngestCachedTelemetry(IngestCachedTelemetryCommand msg)
{
if (_auditIngestProxy == null)
{
_log.Warning(
"Cannot route IngestCachedTelemetryCommand ({0} entries) — audit ingest not available",
msg.Entries.Count);
Sender.Tell(new IngestCachedTelemetryReply(Array.Empty<Guid>()));
return;
}
var replyTo = Sender;
_log.Debug("Routing IngestCachedTelemetryCommand ({0} entries) to the audit ingest actor", msg.Entries.Count);
_auditIngestProxy.Ask<IngestCachedTelemetryReply>(msg, _auditIngestAskTimeout)
.PipeTo(replyTo);
}
private void HandleHeartbeat(HeartbeatMessage heartbeat) private void HandleHeartbeat(HeartbeatMessage heartbeat)
{ {
var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>(); var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>();
@@ -464,3 +574,14 @@ public record DebugStreamTerminated(string SiteId, string CorrelationId);
/// after the outbox singleton proxy is created. /// after the outbox singleton proxy is created.
/// </summary> /// </summary>
public record RegisterNotificationOutbox(IActorRef OutboxProxy); public record RegisterNotificationOutbox(IActorRef OutboxProxy);
/// <summary>
/// Registers the central AuditLogIngestActor singleton proxy with the
/// <see cref="CentralCommunicationActor"/> so site-forwarded
/// <see cref="IngestAuditEventsCommand"/> and <see cref="IngestCachedTelemetryCommand"/>
/// messages can be routed to it. Sent by the Host after the audit-ingest
/// singleton proxy is created. Lives here (not in Commons) because
/// <c>ScadaLink.Commons</c> has no Akka package reference and cannot hold an
/// <see cref="IActorRef"/> field.
/// </summary>
public sealed record RegisterAuditIngest(IActorRef AuditIngestActor);
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Cluster.Tools.Client; using Akka.Cluster.Tools.Client;
using Akka.Event; using Akka.Event;
using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Messages.Artifacts;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.DebugView;
using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Health; using ScadaLink.Commons.Messages.Health;
@@ -166,6 +167,33 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
} }
}); });
// Task 5 (#22): central→site Retry/Discard relay for parked cached
// operations. SiteCallAuditActor relays these over the command/control
// channel; the parked-message handler executes them against the local
// S&F buffer and replies a ParkedOperationActionAck that routes back to
// the relaying SiteCallAuditActor's Ask.
Receive<RetryParkedOperation>(msg =>
{
if (_parkedMessageHandler != null)
_parkedMessageHandler.Forward(msg);
else
{
Sender.Tell(new ParkedOperationActionAck(
msg.CorrelationId, Applied: false, "Parked message handler not available"));
}
});
Receive<DiscardParkedOperation>(msg =>
{
if (_parkedMessageHandler != null)
_parkedMessageHandler.Forward(msg);
else
{
Sender.Tell(new ParkedOperationActionAck(
msg.CorrelationId, Applied: false, "Parked message handler not available"));
}
});
// Notification Outbox: forward a buffered notification submitted by the site // Notification Outbox: forward a buffered notification submitted by the site
// Store-and-Forward Engine to the central cluster. The original Sender (the // Store-and-Forward Engine to the central cluster. The original Sender (the
// S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the // S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the
@@ -214,6 +242,54 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
new ClusterClient.Send("/user/central-communication", msg), Sender); new ClusterClient.Send("/user/central-communication", msg), Sender);
}); });
// Audit Log (#23): forward a batch of site-local audit events to the
// central cluster. The site SiteAuditTelemetryActor drains its SQLite
// Pending queue through the ClusterClientSiteAuditClient, which Asks
// this actor; the original Sender (that Ask) is passed as the
// ClusterClient.Send sender so the IngestAuditEventsReply routes
// straight back to the waiting Ask, not here. Mirrors NotificationSubmit.
Receive<IngestAuditEventsCommand>(msg =>
{
if (_centralClient == null)
{
// No ClusterClient registered yet (e.g. central contact points
// not configured, or registration not yet completed). Faulting
// the Ask makes the SiteAuditTelemetryActor drain loop treat
// this as transient and keep the rows Pending for the next tick.
_log.Warning(
"Cannot forward IngestAuditEventsCommand ({0} events) — no central ClusterClient registered",
msg.Events.Count);
Sender.Tell(new Status.Failure(
new InvalidOperationException("Central ClusterClient not registered")));
return;
}
_log.Debug("Forwarding IngestAuditEventsCommand ({0} events) to central", msg.Events.Count);
_centralClient.Tell(
new ClusterClient.Send("/user/central-communication", msg), Sender);
});
// Audit Log (#23) M3: forward a batch of combined cached-call telemetry
// packets to the central cluster. Same forward + reply-routing pattern
// as IngestAuditEventsCommand; central replies with an
// IngestCachedTelemetryReply.
Receive<IngestCachedTelemetryCommand>(msg =>
{
if (_centralClient == null)
{
_log.Warning(
"Cannot forward IngestCachedTelemetryCommand ({0} entries) — no central ClusterClient registered",
msg.Entries.Count);
Sender.Tell(new Status.Failure(
new InvalidOperationException("Central ClusterClient not registered")));
return;
}
_log.Debug("Forwarding IngestCachedTelemetryCommand ({0} entries) to central", msg.Entries.Count);
_centralClient.Tell(
new ClusterClient.Send("/user/central-communication", msg), Sender);
});
// Internal: send heartbeat tick // Internal: send heartbeat tick
Receive<SendHeartbeat>(_ => SendHeartbeatToCentral()); Receive<SendHeartbeat>(_ => SendHeartbeatToCentral());
@@ -2,6 +2,7 @@ using Akka.Actor;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Messages.Artifacts;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Messages.DebugView; using ScadaLink.Commons.Messages.DebugView;
using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.Health; using ScadaLink.Commons.Messages.Health;
@@ -25,6 +26,7 @@ public class CommunicationService
private readonly ILogger<CommunicationService> _logger; private readonly ILogger<CommunicationService> _logger;
private IActorRef? _centralCommunicationActor; private IActorRef? _centralCommunicationActor;
private IActorRef? _notificationOutboxProxy; private IActorRef? _notificationOutboxProxy;
private IActorRef? _siteCallAuditProxy;
public CommunicationService( public CommunicationService(
IOptions<CommunicationOptions> options, IOptions<CommunicationOptions> options,
@@ -52,6 +54,17 @@ public class CommunicationService
_notificationOutboxProxy = notificationOutboxProxy; _notificationOutboxProxy = notificationOutboxProxy;
} }
/// <summary>
/// Sets the Site Call Audit (#22) singleton proxy reference. Called during
/// actor system startup. The Site Call Audit actor is central-local, so Site
/// Calls read calls Ask this proxy directly (no SiteEnvelope routing), the
/// same pattern as <see cref="SetNotificationOutbox"/>.
/// </summary>
public void SetSiteCallAudit(IActorRef siteCallAuditProxy)
{
_siteCallAuditProxy = siteCallAuditProxy;
}
/// <summary> /// <summary>
/// Triggers an immediate refresh of the site address cache from the database. /// Triggers an immediate refresh of the site address cache from the database.
/// </summary> /// </summary>
@@ -80,6 +93,15 @@ public class CommunicationService
?? throw new InvalidOperationException("CommunicationService not initialized. NotificationOutbox proxy not set."); ?? throw new InvalidOperationException("CommunicationService not initialized. NotificationOutbox proxy not set.");
} }
/// <summary>
/// Gets the Site Call Audit proxy reference. Throws if not yet initialized.
/// </summary>
private IActorRef GetSiteCallAudit()
{
return _siteCallAuditProxy
?? throw new InvalidOperationException("CommunicationService not initialized. SiteCallAudit proxy not set.");
}
// ── Pattern 1: Instance Deployment ── // ── Pattern 1: Instance Deployment ──
public async Task<DeploymentStatusResponse> DeployInstanceAsync( public async Task<DeploymentStatusResponse> DeployInstanceAsync(
@@ -275,6 +297,13 @@ public class CommunicationService
request, _options.QueryTimeout, cancellationToken); request, _options.QueryTimeout, cancellationToken);
} }
public async Task<NotificationDetailResponse> GetNotificationDetailAsync(
NotificationDetailRequest request, CancellationToken cancellationToken = default)
{
return await GetNotificationOutbox().Ask<NotificationDetailResponse>(
request, _options.QueryTimeout, cancellationToken);
}
public async Task<NotificationKpiResponse> GetNotificationKpisAsync( public async Task<NotificationKpiResponse> GetNotificationKpisAsync(
NotificationKpiRequest request, CancellationToken cancellationToken = default) NotificationKpiRequest request, CancellationToken cancellationToken = default)
{ {
@@ -288,6 +317,71 @@ public class CommunicationService
return await GetNotificationOutbox().Ask<PerSiteNotificationKpiResponse>( return await GetNotificationOutbox().Ask<PerSiteNotificationKpiResponse>(
request, _options.QueryTimeout, cancellationToken); request, _options.QueryTimeout, cancellationToken);
} }
// ── Site Call Audit (central-local actor — Asked directly, no SiteEnvelope) ──
public async Task<SiteCallQueryResponse> QuerySiteCallsAsync(
SiteCallQueryRequest request, CancellationToken cancellationToken = default)
{
return await GetSiteCallAudit().Ask<SiteCallQueryResponse>(
request, _options.QueryTimeout, cancellationToken);
}
public async Task<SiteCallDetailResponse> GetSiteCallDetailAsync(
SiteCallDetailRequest request, CancellationToken cancellationToken = default)
{
return await GetSiteCallAudit().Ask<SiteCallDetailResponse>(
request, _options.QueryTimeout, cancellationToken);
}
public async Task<SiteCallKpiResponse> GetSiteCallKpisAsync(
SiteCallKpiRequest request, CancellationToken cancellationToken = default)
{
return await GetSiteCallAudit().Ask<SiteCallKpiResponse>(
request, _options.QueryTimeout, cancellationToken);
}
public async Task<PerSiteSiteCallKpiResponse> GetPerSiteSiteCallKpisAsync(
PerSiteSiteCallKpiRequest request, CancellationToken cancellationToken = default)
{
return await GetSiteCallAudit().Ask<PerSiteSiteCallKpiResponse>(
request, _options.QueryTimeout, cancellationToken);
}
/// <summary>
/// Task 5 (#22): relays an operator Retry of a parked cached call to its
/// owning site. The <c>SiteCallAuditActor</c> is Asked directly (it is
/// central-local); it in turn relays a <c>RetryParkedOperation</c> to the
/// owning site and replies a <see cref="RetrySiteCallResponse"/> carrying a
/// distinct site-unreachable outcome. Central never mutates the central
/// <c>SiteCalls</c> mirror row.
/// <para>
/// This outer Ask uses <see cref="CommunicationOptions.QueryTimeout"/>
/// (default 30s), which must outlive the inner site relay Ask the
/// <c>SiteCallAuditActor</c> issues with <c>SiteCallAuditOptions.RelayTimeout</c>
/// (default 10s). The inner relay must time out first so its distinct
/// <c>SiteUnreachable</c> outcome reaches us; were this outer Ask to expire
/// first, that outcome would be lost to a generic Ask-timeout exception.
/// </para>
/// </summary>
public async Task<RetrySiteCallResponse> RetrySiteCallAsync(
RetrySiteCallRequest request, CancellationToken cancellationToken = default)
{
return await GetSiteCallAudit().Ask<RetrySiteCallResponse>(
request, _options.QueryTimeout, cancellationToken);
}
/// <summary>
/// Task 5 (#22): relays an operator Discard of a parked cached call to its
/// owning site. See <see cref="RetrySiteCallAsync"/> for the routing and
/// source-of-truth rationale.
/// </summary>
public async Task<DiscardSiteCallResponse> DiscardSiteCallAsync(
DiscardSiteCallRequest request, CancellationToken cancellationToken = default)
{
return await GetSiteCallAudit().Ask<DiscardSiteCallResponse>(
request, _options.QueryTimeout, cancellationToken);
}
} }
/// <summary> /// <summary>
@@ -1,16 +1,24 @@
using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Enums;
using ScadaLink.Communication.Grpc;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp; using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
namespace ScadaLink.AuditLog.Telemetry; namespace ScadaLink.Communication.Grpc;
/// <summary> /// <summary>
/// Bridges Audit Log (#23) rows between the in-process <see cref="AuditEvent"/> record /// Canonical bridge for Audit Log (#23) rows between the in-process
/// and the wire-format <see cref="AuditEventDto"/> exchanged over the /// <see cref="AuditEvent"/> record and the wire-format <see cref="AuditEventDto"/>
/// <c>IngestAuditEvents</c> RPC. /// exchanged over the <c>IngestAuditEvents</c>, <c>IngestCachedTelemetry</c> and
/// <c>PullAuditEvents</c> RPCs.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// This mapper lives in <c>ScadaLink.Communication</c> (which owns the generated
/// <see cref="AuditEventDto"/> and references <c>Commons</c> for
/// <see cref="AuditEvent"/>) so both <c>SiteStreamGrpcServer</c> and
/// <c>ScadaLink.AuditLog</c> can share one implementation without the
/// project-reference cycle that would result from hosting it in
/// <c>ScadaLink.AuditLog</c> (AuditLog → Communication, never the reverse).
/// </para>
/// <para><b>Lossy by design:</b> the proto contract intentionally omits two fields.</para> /// <para><b>Lossy by design:</b> the proto contract intentionally omits two fields.</para>
/// <list type="bullet"> /// <list type="bullet">
/// <item><see cref="AuditEvent.ForwardState"/> — site-local SQLite state, never travels.</item> /// <item><see cref="AuditEvent.ForwardState"/> — site-local SQLite state, never travels.</item>
@@ -22,7 +30,7 @@ namespace ScadaLink.AuditLog.Telemetry;
/// <c>Int32Value</c> wrapper so they preserve true null semantics. /// <c>Int32Value</c> wrapper so they preserve true null semantics.
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class AuditEventMapper public static class AuditEventDtoMapper
{ {
/// <summary> /// <summary>
/// Projects an <see cref="AuditEvent"/> into its wire-format DTO. Null reference /// Projects an <see cref="AuditEvent"/> into its wire-format DTO. Null reference
@@ -39,6 +47,7 @@ public static class AuditEventMapper
Channel = evt.Channel.ToString(), Channel = evt.Channel.ToString(),
Kind = evt.Kind.ToString(), Kind = evt.Kind.ToString(),
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty, CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
SourceSiteId = evt.SourceSiteId ?? string.Empty, SourceSiteId = evt.SourceSiteId ?? string.Empty,
SourceInstanceId = evt.SourceInstanceId ?? string.Empty, SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
SourceScript = evt.SourceScript ?? string.Empty, SourceScript = evt.SourceScript ?? string.Empty,
@@ -84,6 +93,7 @@ public static class AuditEventMapper
Channel = Enum.Parse<AuditChannel>(dto.Channel), Channel = Enum.Parse<AuditChannel>(dto.Channel),
Kind = Enum.Parse<AuditKind>(dto.Kind), Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null, CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
SourceSiteId = NullIfEmpty(dto.SourceSiteId), SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript), SourceScript = NullIfEmpty(dto.SourceScript),
@@ -0,0 +1,70 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types;
namespace ScadaLink.Communication.Grpc;
/// <summary>
/// Canonical bridge for Site Call Audit (#22) operational rows between the
/// wire-format <see cref="SiteCallOperationalDto"/> exchanged on the
/// <c>CachedCallTelemetry</c> packet and the in-process <see cref="SiteCall"/>
/// persistence entity central writes into the <c>SiteCalls</c> table.
/// </summary>
/// <remarks>
/// <para>
/// This mapper lives in <c>ScadaLink.Communication</c> (which owns the generated
/// <see cref="SiteCallOperationalDto"/> and references <c>Commons</c> for
/// <see cref="SiteCall"/>) so both <c>SiteStreamGrpcServer</c> and
/// <c>ScadaLink.AuditLog</c> can share one implementation without the
/// project-reference cycle that would result from hosting it in
/// <c>ScadaLink.AuditLog</c> (AuditLog → Communication, never the reverse).
/// Mirrors the sibling <see cref="AuditEventDtoMapper"/>.
/// </para>
/// <para>
/// Only the DTO→entity direction is provided: nothing in the system maps a
/// <see cref="SiteCall"/> back onto the wire (sites emit the operational state
/// from <c>SiteCallOperational</c>, never from the central <see cref="SiteCall"/>
/// entity), so an entity→DTO method would be dead code.
/// </para>
/// <para>
/// String nullability convention: proto3 scalar strings cannot be absent, so the
/// optional <see cref="SiteCall.LastError"/> rehydrates from an empty string back
/// to null. The optional <c>HttpStatus</c> and <c>TerminalAtUtc</c> use proto
/// wrappers so they preserve true null semantics.
/// </para>
/// </remarks>
public static class SiteCallDtoMapper
{
/// <summary>
/// Reconstructs a <see cref="SiteCall"/> persistence entity from its
/// wire-format DTO. An empty <c>LastError</c> rehydrates as null; absent
/// <c>HttpStatus</c>/<c>TerminalAtUtc</c> wrappers stay null.
/// </summary>
/// <remarks>
/// <see cref="SiteCall.IngestedAtUtc"/> is stamped here as a placeholder
/// (<see cref="DateTime.UtcNow"/>); the central ingest actor overwrites it
/// inside the dual-write transaction so the AuditLog and SiteCalls rows
/// share one instant. The value sent on the wire is informational only.
/// </remarks>
public static SiteCall FromDto(SiteCallOperationalDto dto)
{
ArgumentNullException.ThrowIfNull(dto);
return new SiteCall
{
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
Channel = dto.Channel,
Target = dto.Target,
SourceSite = dto.SourceSite,
Status = dto.Status,
RetryCount = dto.RetryCount,
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
HttpStatus = dto.HttpStatus,
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
TerminalAtUtc = dto.TerminalAtUtc is null
? null
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
};
}
}
@@ -7,8 +7,6 @@ using Microsoft.Extensions.Options;
using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Audit; using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using GrpcStatus = Grpc.Core.Status; using GrpcStatus = Grpc.Core.Status;
namespace ScadaLink.Communication.Grpc; namespace ScadaLink.Communication.Grpc;
@@ -224,13 +222,10 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The DTO→entity conversion is inlined here (rather than calling the /// The DTO→entity conversion uses the shared <see cref="AuditEventDtoMapper"/>
/// AuditLog mapper) to avoid a project-reference cycle: /// (hosted in <c>ScadaLink.Communication</c> so both this server and
/// <c>ScadaLink.AuditLog</c> already references /// <c>ScadaLink.AuditLog</c> share one implementation without a
/// <c>ScadaLink.Communication</c>, so the gRPC server cannot reach back /// project-reference cycle).
/// into AuditLog for its mapper. The shape mirrors
/// <c>AuditEventMapper.FromDto</c> in <c>ScadaLink.AuditLog.Telemetry</c>;
/// the two must evolve together.
/// </para> /// </para>
/// <para> /// <para>
/// When <see cref="_auditIngestActor"/> is not yet wired (host startup /// When <see cref="_auditIngestActor"/> is not yet wired (host startup
@@ -262,36 +257,10 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
return new IngestAck(); return new IngestAck();
} }
// Inlined FromDto. Keep in sync with AuditEventMapper.FromDto in
// ScadaLink.AuditLog.Telemetry — there is no shared mapper because
// doing so would create a project-reference cycle (AuditLog → Communication).
var entities = new List<AuditEvent>(request.Events.Count); var entities = new List<AuditEvent>(request.Events.Count);
foreach (var dto in request.Events) foreach (var dto in request.Events)
{ {
entities.Add(new AuditEvent entities.Add(AuditEventDtoMapper.FromDto(dto));
{
EventId = Guid.Parse(dto.EventId),
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = Enum.Parse<AuditChannel>(dto.Channel),
Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = string.IsNullOrEmpty(dto.CorrelationId) ? null : Guid.Parse(dto.CorrelationId),
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript),
Actor = NullIfEmpty(dto.Actor),
Target = NullIfEmpty(dto.Target),
Status = Enum.Parse<AuditStatus>(dto.Status),
HttpStatus = dto.HttpStatus,
DurationMs = dto.DurationMs,
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
RequestSummary = NullIfEmpty(dto.RequestSummary),
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
PayloadTruncated = dto.PayloadTruncated,
Extra = NullIfEmpty(dto.Extra),
ForwardState = null,
});
} }
var cmd = new IngestAuditEventsCommand(entities); var cmd = new IngestAuditEventsCommand(entities);
@@ -355,8 +324,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
var entries = new List<CachedTelemetryEntry>(request.Packets.Count); var entries = new List<CachedTelemetryEntry>(request.Packets.Count);
foreach (var packet in request.Packets) foreach (var packet in request.Packets)
{ {
var auditEvent = MapAuditEventFromDto(packet.AuditEvent); var auditEvent = AuditEventDtoMapper.FromDto(packet.AuditEvent);
var siteCall = MapSiteCallFromDto(packet.Operational); var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
entries.Add(new CachedTelemetryEntry(auditEvent, siteCall)); entries.Add(new CachedTelemetryEntry(auditEvent, siteCall));
} }
@@ -450,7 +419,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
}; };
foreach (var evt in events) foreach (var evt in events)
{ {
response.Events.Add(AuditEventToDto(evt)); response.Events.Add(AuditEventDtoMapper.ToDto(evt));
} }
// Flip to Reconciled AFTER projecting the response so a fault below the // Flip to Reconciled AFTER projecting the response so a fault below the
@@ -481,110 +450,6 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
return response; return response;
} }
/// <summary>
/// Inlined audit-event entity→DTO translation. Keep in sync with
/// <c>AuditEventMapper.ToDto</c> in <c>ScadaLink.AuditLog.Telemetry</c> —
/// the project-reference cycle (AuditLog → Communication) prevents calling
/// the AuditLog mapper directly. The shape mirrors the FromDto pair above.
/// </summary>
private static AuditEventDto AuditEventToDto(AuditEvent evt)
{
var dto = new AuditEventDto
{
EventId = evt.EventId.ToString(),
OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
Channel = evt.Channel.ToString(),
Kind = evt.Kind.ToString(),
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
SourceSiteId = evt.SourceSiteId ?? string.Empty,
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
SourceScript = evt.SourceScript ?? string.Empty,
Actor = evt.Actor ?? string.Empty,
Target = evt.Target ?? string.Empty,
Status = evt.Status.ToString(),
ErrorMessage = evt.ErrorMessage ?? string.Empty,
ErrorDetail = evt.ErrorDetail ?? string.Empty,
RequestSummary = evt.RequestSummary ?? string.Empty,
ResponseSummary = evt.ResponseSummary ?? string.Empty,
PayloadTruncated = evt.PayloadTruncated,
Extra = evt.Extra ?? string.Empty,
};
if (evt.HttpStatus.HasValue) dto.HttpStatus = evt.HttpStatus.Value;
if (evt.DurationMs.HasValue) dto.DurationMs = evt.DurationMs.Value;
return dto;
}
private static DateTime EnsureUtc(DateTime value) =>
value.Kind == DateTimeKind.Utc
? value
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
private static string? NullIfEmpty(string? value) =>
string.IsNullOrEmpty(value) ? null : value;
/// <summary>
/// Inlined audit-event DTO→entity translation, kept in sync with the
/// <see cref="IngestAuditEvents"/> handler above. Extracted to a private
/// helper so the M3 dual-write RPC can reuse it without duplicating yet
/// another copy. The shape still mirrors
/// <c>AuditEventMapper.FromDto</c> in <c>ScadaLink.AuditLog.Telemetry</c>;
/// the two must evolve together (the project-reference cycle that
/// prevents calling the AuditLog mapper directly is documented on
/// <see cref="IngestAuditEvents"/>).
/// </summary>
private static AuditEvent MapAuditEventFromDto(AuditEventDto dto) =>
new()
{
EventId = Guid.Parse(dto.EventId),
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc = null,
Channel = Enum.Parse<AuditChannel>(dto.Channel),
Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript),
Actor = NullIfEmpty(dto.Actor),
Target = NullIfEmpty(dto.Target),
Status = Enum.Parse<AuditStatus>(dto.Status),
HttpStatus = dto.HttpStatus,
DurationMs = dto.DurationMs,
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
RequestSummary = NullIfEmpty(dto.RequestSummary),
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
PayloadTruncated = dto.PayloadTruncated,
Extra = NullIfEmpty(dto.Extra),
ForwardState = null,
};
/// <summary>
/// Translates a <see cref="SiteCallOperationalDto"/> into the persistence
/// entity. <see cref="SiteCall.IngestedAtUtc"/> is stamped here as a
/// placeholder; the central ingest actor overwrites it inside the
/// dual-write transaction so the AuditLog and SiteCalls rows share one
/// instant.
/// </summary>
private static SiteCall MapSiteCallFromDto(SiteCallOperationalDto dto) => new()
{
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
Channel = dto.Channel,
Target = dto.Target,
SourceSite = dto.SourceSite,
Status = dto.Status,
RetryCount = dto.RetryCount,
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
HttpStatus = dto.HttpStatus,
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
TerminalAtUtc = dto.TerminalAtUtc is null
? null
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
};
/// <summary> /// <summary>
/// Tracks a single active stream so cleanup only removes its own entry. /// Tracks a single active stream so cleanup only removes its own entry.
/// </summary> /// </summary>
@@ -91,6 +91,7 @@ message AuditEventDto {
string response_summary = 17; string response_summary = 17;
bool payload_truncated = 18; bool payload_truncated = 18;
string extra = 19; string extra = 19;
string execution_id = 20; // empty string represents null
} }
message AuditEventBatch { repeated AuditEventDto events = 1; } message AuditEventBatch { repeated AuditEventDto events = 1; }
@@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc {
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy", "c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90", "aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs", "b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAki9QMKDUF1ZGl0RXZlbnRE", "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiiwQKDUF1ZGl0RXZlbnRE",
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL", "dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ", "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291", "EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
@@ -52,43 +52,43 @@ namespace ScadaLink.Communication.Grpc {
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf", "GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz", "bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR", "dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk", "cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
"aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk", "Y3V0aW9uX2lkGBQgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZldmVudHMY",
"aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz", "ASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5nZXN0QWNr",
"GAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRf", "EhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSL0AgoWU2l0ZUNhbGxPcGVy",
"b3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0", "YXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEoCRIPCgdj",
"GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRIT", "aGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vfc2l0ZRgE",
"CgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0", "IAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiABKAUSEgoK",
"dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUS", "bGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIbLmdvb2ds",
"MgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt", "ZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRjGAkgASgL",
"ZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3Rv", "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVkX2F0X3V0",
"YnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29n", "YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoPdGVybWlu",
"bGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr", "YWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKA",
"ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl", "AQoVQ2FjaGVkVGVsZW1ldHJ5UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgL",
"bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD", "Mhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIg",
"YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH", "ASgLMiIuc2l0ZXN0cmVhbS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENh",
"cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj", "Y2hlZFRlbGVtZXRyeUJhdGNoEjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3Ry",
"a2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRjGAEg", "ZWFtLkNhY2hlZFRlbGVtZXRyeVBhY2tldCJbChZQdWxsQXVkaXRFdmVudHNS",
"ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXpl", "ZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U",
"GAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50cxgB", "aW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJcChdQdWxsQXVkaXRFdmVu",
"IAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2YWls", "dHNSZXNwb25zZRIpCgZldmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0",
"YWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQ", "RXZlbnREdG8SFgoObW9yZV9hdmFpbGFibGUYAiABKAgqXAoHUXVhbGl0eRIX",
"ABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8K", "ChNRVUFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoR",
"C1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RB", "UVVBTElUWV9VTkNFUlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJt",
"VEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJB", "U3RhdGVFbnVtEhsKF0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxB",
"TEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxB", "Uk1fU1RBVEVfTk9STUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIqhQEK",
"Uk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJN", "DkFsYXJtTGV2ZWxFbnVtEhQKEEFMQVJNX0xFVkVMX05PTkUQABITCg9BTEFS",
"X0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxB", "TV9MRVZFTF9MT1cQARIXChNBTEFSTV9MRVZFTF9MT1dfTE9XEAISFAoQQUxB",
"Uk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNlElUK", "Uk1fTEVWRUxfSElHSBADEhkKFUFMQVJNX0xFVkVMX0hJR0hfSElHSBAEMuEC",
"EVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVh", "ChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0YW5jZRIhLnNp",
"bVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcKEUlu", "dGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5T",
"Z2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0Y2ga", "aXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVz",
"FS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxlbWV0", "dHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sS",
"cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0ZXN0", "UAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRU",
"cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0cmVh", "ZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloKD1B1bGxB",
"bS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsQXVk", "dWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVxdWVz",
"aXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRpb24u", "dBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2VCH6oCHFNj",
"R3JwY2IGcHJvdG8z")); "YWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, }, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
@@ -96,7 +96,7 @@ namespace ScadaLink.Communication.Grpc {
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null),
@@ -1591,6 +1591,7 @@ namespace ScadaLink.Communication.Grpc {
responseSummary_ = other.responseSummary_; responseSummary_ = other.responseSummary_;
payloadTruncated_ = other.payloadTruncated_; payloadTruncated_ = other.payloadTruncated_;
extra_ = other.extra_; extra_ = other.extra_;
executionId_ = other.executionId_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
} }
@@ -1838,6 +1839,21 @@ namespace ScadaLink.Communication.Grpc {
} }
} }
/// <summary>Field number for the "execution_id" field.</summary>
public const int ExecutionIdFieldNumber = 20;
private string executionId_ = "";
/// <summary>
/// empty string represents null
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string ExecutionId {
get { return executionId_; }
set {
executionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) { public override bool Equals(object other) {
@@ -1872,6 +1888,7 @@ namespace ScadaLink.Communication.Grpc {
if (ResponseSummary != other.ResponseSummary) return false; if (ResponseSummary != other.ResponseSummary) return false;
if (PayloadTruncated != other.PayloadTruncated) return false; if (PayloadTruncated != other.PayloadTruncated) return false;
if (Extra != other.Extra) return false; if (Extra != other.Extra) return false;
if (ExecutionId != other.ExecutionId) return false;
return Equals(_unknownFields, other._unknownFields); return Equals(_unknownFields, other._unknownFields);
} }
@@ -1898,6 +1915,7 @@ namespace ScadaLink.Communication.Grpc {
if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode(); if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode();
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode(); if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
if (Extra.Length != 0) hash ^= Extra.GetHashCode(); if (Extra.Length != 0) hash ^= Extra.GetHashCode();
if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode();
if (_unknownFields != null) { if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode(); hash ^= _unknownFields.GetHashCode();
} }
@@ -1990,6 +2008,10 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(154, 1); output.WriteRawTag(154, 1);
output.WriteString(Extra); output.WriteString(Extra);
} }
if (ExecutionId.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(output); _unknownFields.WriteTo(output);
} }
@@ -2074,6 +2096,10 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(154, 1); output.WriteRawTag(154, 1);
output.WriteString(Extra); output.WriteString(Extra);
} }
if (ExecutionId.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(ref output); _unknownFields.WriteTo(ref output);
} }
@@ -2141,6 +2167,9 @@ namespace ScadaLink.Communication.Grpc {
if (Extra.Length != 0) { if (Extra.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra); size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra);
} }
if (ExecutionId.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId);
}
if (_unknownFields != null) { if (_unknownFields != null) {
size += _unknownFields.CalculateSize(); size += _unknownFields.CalculateSize();
} }
@@ -2217,6 +2246,9 @@ namespace ScadaLink.Communication.Grpc {
if (other.Extra.Length != 0) { if (other.Extra.Length != 0) {
Extra = other.Extra; Extra = other.Extra;
} }
if (other.ExecutionId.Length != 0) {
ExecutionId = other.ExecutionId;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
} }
@@ -2321,6 +2353,10 @@ namespace ScadaLink.Communication.Grpc {
Extra = input.ReadString(); Extra = input.ReadString();
break; break;
} }
case 162: {
ExecutionId = input.ReadString();
break;
}
} }
} }
#endif #endif
@@ -2425,6 +2461,10 @@ namespace ScadaLink.Communication.Grpc {
Extra = input.ReadString(); Extra = input.ReadString();
break; break;
} }
case 162: {
ExecutionId = input.ReadString();
break;
}
} }
} }
} }
@@ -89,6 +89,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
.HasFilter("[CorrelationId] IS NOT NULL") .HasFilter("[CorrelationId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_CorrelationId"); .HasDatabaseName("IX_AuditLog_CorrelationId");
builder.HasIndex(e => e.ExecutionId)
.HasFilter("[ExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_Execution");
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc }) builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
.IsDescending(false, false, true) .IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
@@ -47,6 +47,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
builder.Property(n => n.SourceScript).HasMaxLength(200); builder.Property(n => n.SourceScript).HasMaxLength(200);
// OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the
// site so the dispatcher can echo it onto NotifyDeliver audit rows. No index —
// it is never a query predicate on this table, only copied onto audit events.
builder.HasIndex(n => new { n.Status, n.NextAttemptAt }); builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the universal <c>ExecutionId</c> correlation column to the centralized
/// <c>AuditLog</c> table (#23). <c>ExecutionId</c> identifies the originating
/// script execution / inbound request and is distinct from the per-operation
/// <c>CorrelationId</c>.
///
/// The change is purely additive:
/// 1. <c>ExecutionId uniqueidentifier NULL</c> is added with no default, so the
/// operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT
/// rewrite the monthly-partitioned <c>AuditLog</c> table, and historical
/// rows stay <c>NULL</c> (no backfill).
/// 2. <c>IX_AuditLog_Execution</c> is created via raw SQL so it lands on the
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching every
/// other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned preserves
/// the partition-switch purge path (see AuditLogRepository.SwitchOutPartitionAsync).
/// </summary>
public partial class AddAuditLogExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ExecutionId",
table: "AuditLog",
type: "uniqueidentifier",
nullable: true);
// Raw SQL so the index is created on the partition scheme — EF's
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned).
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
ON dbo.AuditLog (ExecutionId)
WHERE ExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Execution' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Execution ON dbo.AuditLog;");
migrationBuilder.DropColumn(
name: "ExecutionId",
table: "AuditLog");
}
}
}
@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>OriginExecutionId</c> correlation column to the central
/// <c>Notifications</c> table (#21). It carries the originating script execution's
/// <c>ExecutionId</c> from the site so the dispatcher can echo it onto the
/// <c>NotifyDeliver</c> audit rows (#23), linking them to the site's <c>NotifySend</c>
/// row for the same run.
///
/// The change is purely additive: <c>OriginExecutionId uniqueidentifier NULL</c> is
/// added with no default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>.
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is NOT partitioned, so a
/// plain <c>ADD</c> is fine. No index is created — the column is never a query
/// predicate, only copied onto audit events. Historical rows stay <c>NULL</c>.
/// </summary>
public partial class AddNotificationOriginExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OriginExecutionId",
table: "Notifications",
type: "uniqueidentifier",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OriginExecutionId",
table: "Notifications");
}
}
}
@@ -73,6 +73,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("nvarchar(1024)"); .HasColumnType("nvarchar(1024)");
b.Property<Guid?>("ExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Extra") b.Property<string>("Extra")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -138,6 +141,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnique() .IsUnique()
.HasDatabaseName("UX_AuditLog_EventId"); .HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("ExecutionId")
.HasDatabaseName("IX_AuditLog_Execution")
.HasFilter("[ExecutionId] IS NOT NULL");
b.HasIndex("OccurredAtUtc") b.HasIndex("OccurredAtUtc")
.IsDescending() .IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc"); .HasDatabaseName("IX_AuditLog_OccurredAtUtc");
@@ -780,6 +787,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<DateTimeOffset?>("NextAttemptAt") b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("datetimeoffset"); .HasColumnType("datetimeoffset");
b.Property<Guid?>("OriginExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ResolvedTargets") b.Property<string>("ResolvedTargets")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
await _context.Database.ExecuteSqlInterpolatedAsync( await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId}) $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState) ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId},
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status}, {evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary}, {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});", {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
@@ -116,25 +116,28 @@ VALUES
var query = _context.Set<AuditEvent>().AsNoTracking(); var query = _context.Set<AuditEvent>().AsNoTracking();
if (filter.Channel is { } channel) // Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
// WHERE 1=0). A non-empty list translates to a SQL IN (…) via EF Core's
// IReadOnlyList<T>.Contains support — server-side, no client-eval.
if (filter.Channels is { Count: > 0 } channels)
{ {
query = query.Where(e => e.Channel == channel); query = query.Where(e => channels.Contains(e.Channel));
} }
if (filter.Kind is { } kind) if (filter.Kinds is { Count: > 0 } kinds)
{ {
query = query.Where(e => e.Kind == kind); query = query.Where(e => kinds.Contains(e.Kind));
} }
if (filter.Status is { } status) if (filter.Statuses is { Count: > 0 } statuses)
{ {
query = query.Where(e => e.Status == status); query = query.Where(e => statuses.Contains(e.Status));
} }
if (!string.IsNullOrEmpty(filter.SourceSiteId)) if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{ {
var siteId = filter.SourceSiteId; query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
query = query.Where(e => e.SourceSiteId == siteId);
} }
if (!string.IsNullOrEmpty(filter.Target)) if (!string.IsNullOrEmpty(filter.Target))
@@ -154,6 +157,11 @@ VALUES
query = query.Where(e => e.CorrelationId == correlationId); query = query.Where(e => e.CorrelationId == correlationId);
} }
if (filter.ExecutionId is { } executionId)
{
query = query.Where(e => e.ExecutionId == executionId);
}
if (filter.FromUtc is { } fromUtc) if (filter.FromUtc is { } fromUtc)
{ {
query = query.Where(e => e.OccurredAtUtc >= fromUtc); query = query.Where(e => e.OccurredAtUtc >= fromUtc);
@@ -260,6 +268,10 @@ VALUES
PayloadTruncated bit NOT NULL, PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL, Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL, ForwardState varchar(32) NULL,
-- ExecutionId is last because it was added to the live AuditLog table by a later
-- ALTER TABLE ADD migration; the staging table must match the live table column
-- shape ordinal-for-ordinal or ALTER TABLE ... SWITCH PARTITION fails.
ExecutionId uniqueidentifier NULL,
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY]; ) ON [PRIMARY];
@@ -164,7 +164,13 @@ WHERE TrackedOperationId = {idText}
var fromUtc = filter.FromUtc; var fromUtc = filter.FromUtc;
var toUtc = filter.ToUtc; var toUtc = filter.ToUtc;
var stuckCutoff = filter.StuckCutoffUtc;
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
// is pushed into SQL here — both columns are plain (no value converter)
// and compose with the keyset cursor, so a StuckOnly page is honest:
// never under-filled with a non-null next cursor. Mirrors how
// NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff.
FormattableString sql = $@" FormattableString sql = $@"
SELECT TOP ({paging.PageSize}) SELECT TOP ({paging.PageSize})
TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount, TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount,
@@ -176,6 +182,7 @@ WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
AND ({filter.Target} IS NULL OR Target = {filter.Target}) AND ({filter.Target} IS NULL OR Target = {filter.Target})
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc}) AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc}) AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
AND ({stuckCutoff} IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < {stuckCutoff}))
AND ({(hasCursor ? 1 : 0)} = 0 AND ({(hasCursor ? 1 : 0)} = 0
OR CreatedAtUtc < {afterCreated} OR CreatedAtUtc < {afterCreated}
OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString})) OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
@@ -201,6 +208,141 @@ ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
ct); ct);
} }
// Terminal status string literals for the interval-throughput KPIs. The
// Status column is a plain varchar (no value converter), so these compare
// directly in translated SQL.
//
// NOTE on the "buffered/non-terminal" definition: the SiteCalls operational
// mirror stores AuditStatus-derived strings (Attempted/Delivered/Parked/
// Failed/...), NOT the tracking-lifecycle Pending/Retrying names the spec's
// KPI section uses. There is therefore no Status string that means
// "buffered". The schema-honest predicate for "non-terminal / buffered" is
// TerminalAtUtc IS NULL — consistent with PurgeTerminalAsync's terminal
// predicate and with the SiteCall entity's own contract ("TerminalAtUtc ...
// null while still active"). All buffered / stuck / oldest-pending counts
// below key off TerminalAtUtc, not Status.
private const string StatusParked = "Parked";
private const string StatusDelivered = "Delivered";
private const string StatusFailed = "Failed";
/// <summary>
/// Computes the global KPI snapshot with five server-side aggregate queries
/// against <c>dbo.SiteCalls</c>. No rows are materialised — every count is a
/// translated <c>COUNT</c> and the oldest-pending age is a translated
/// <c>MIN(CreatedAtUtc)</c>. The <c>Status</c> and <c>CreatedAtUtc</c>/<c>TerminalAtUtc</c>
/// columns have no value converter, so the aggregates translate cleanly to
/// SQL Server (unlike the NotificationOutbox's <c>DateTimeOffset</c>-converted
/// column, which forces an order-and-take). "Buffered" / "stuck" key off
/// <c>TerminalAtUtc IS NULL</c> — see the field comments above.
/// </summary>
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var bufferedCount = await _context.SiteCalls
.CountAsync(s => s.TerminalAtUtc == null, ct);
var parkedCount = await _context.SiteCalls
.CountAsync(s => s.Status == StatusParked, ct);
var failedLastInterval = await _context.SiteCalls
.CountAsync(s => s.Status == StatusFailed
&& s.TerminalAtUtc != null
&& s.TerminalAtUtc >= intervalSince, ct);
var deliveredLastInterval = await _context.SiteCalls
.CountAsync(s => s.Status == StatusDelivered
&& s.TerminalAtUtc != null
&& s.TerminalAtUtc >= intervalSince, ct);
var stuckCount = await _context.SiteCalls
.CountAsync(s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
var nonTerminal = _context.SiteCalls.Where(s => s.TerminalAtUtc == null);
TimeSpan? oldestPendingAge = null;
if (await nonTerminal.AnyAsync(ct))
{
var oldestCreatedAt = await nonTerminal.MinAsync(s => s.CreatedAtUtc, ct);
oldestPendingAge = now - oldestCreatedAt;
}
return new SiteCallKpiSnapshot(
BufferedCount: bufferedCount,
ParkedCount: parkedCount,
FailedLastInterval: failedLastInterval,
DeliveredLastInterval: deliveredLastInterval,
OldestPendingAge: oldestPendingAge,
StuckCount: stuckCount);
}
/// <summary>
/// Computes the per-source-site KPI breakdown. The five counts are
/// <c>GROUP BY SourceSite</c> aggregates; the oldest-pending age is a
/// per-site <c>MIN(CreatedAtUtc)</c> over the (bounded) non-terminal set —
/// all run server-side. A site appears in the result only if it has at
/// least one row matched by one of the count queries. "Buffered" / "stuck"
/// key off <c>TerminalAtUtc IS NULL</c> — see <see cref="ComputeKpisAsync"/>.
/// </summary>
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var buffered = await CountBySiteAsync(s => s.TerminalAtUtc == null, ct);
var parked = await CountBySiteAsync(s => s.Status == StatusParked, ct);
var failed = await CountBySiteAsync(
s => s.Status == StatusFailed
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
var delivered = await CountBySiteAsync(
s => s.Status == StatusDelivered
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
var stuck = await CountBySiteAsync(
s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
// Oldest non-terminal CreatedAtUtc per site — a server-side GROUP BY MIN.
var oldest = (await _context.SiteCalls
.Where(s => s.TerminalAtUtc == null)
.GroupBy(s => s.SourceSite)
.Select(g => new { Site = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) })
.ToListAsync(ct))
.ToDictionary(x => x.Site, x => x.Oldest);
var siteIds = buffered.Keys
.Concat(parked.Keys).Concat(failed.Keys)
.Concat(delivered.Keys).Concat(stuck.Keys)
.Distinct()
.OrderBy(s => s, StringComparer.Ordinal);
return siteIds.Select(site => new SiteCallSiteKpiSnapshot(
SourceSite: site,
BufferedCount: buffered.GetValueOrDefault(site),
ParkedCount: parked.GetValueOrDefault(site),
FailedLastInterval: failed.GetValueOrDefault(site),
DeliveredLastInterval: delivered.GetValueOrDefault(site),
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
? now - createdAt
: null,
StuckCount: stuck.GetValueOrDefault(site))).ToList();
}
/// <summary>Counts <c>SiteCalls</c> rows matching <paramref name="predicate"/>, grouped by source site.</summary>
private async Task<Dictionary<string, int>> CountBySiteAsync(
System.Linq.Expressions.Expression<Func<SiteCall, bool>> predicate,
CancellationToken ct)
{
return await _context.SiteCalls
.Where(predicate)
.GroupBy(s => s.SourceSite)
.Select(g => new { Site = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Site, x => x.Count, ct);
}
private static int GetRankOrThrow(string status) private static int GetRankOrThrow(string status)
{ {
if (!StatusRank.TryGetValue(status, out var rank)) if (!StatusRank.TryGetValue(status, out var rank))
@@ -84,7 +84,9 @@ public class DatabaseGateway : IDatabaseGateway
IReadOnlyDictionary<string, object?>? parameters = null, IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null, string? originInstanceName = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null) TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null)
{ {
var definition = await ResolveConnectionAsync(connectionName, cancellationToken); var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
if (definition == null) if (definition == null)
@@ -124,7 +126,13 @@ public class DatabaseGateway : IDatabaseGateway
// read it back via StoreAndForwardMessage.Id and emit per-attempt + // read it back via StoreAndForwardMessage.Id and emit per-attempt +
// terminal cached-write telemetry. Null -> S&F mints its own GUID // terminal cached-write telemetry. Null -> S&F mints its own GUID
// (legacy pre-M3 behaviour). // (legacy pre-M3 behaviour).
messageId: trackedOperationId?.ToString()); messageId: trackedOperationId?.ToString(),
// Audit Log #23 (ExecutionId Task 4): thread the originating script
// execution's ExecutionId + SourceScript onto the buffered row so
// the retry-loop cached-write audit rows carry the same provenance
// the script-side cached rows do.
executionId: executionId,
sourceScript: sourceScript);
} }
/// <summary> /// <summary>
@@ -86,7 +86,9 @@ public class ExternalSystemClient : IExternalSystemClient
IReadOnlyDictionary<string, object?>? parameters = null, IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null, string? originInstanceName = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null) TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null)
{ {
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken); var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
if (system == null || method == null) if (system == null || method == null)
@@ -144,7 +146,13 @@ public class ExternalSystemClient : IExternalSystemClient
// StoreAndForwardMessage.Id and emit per-attempt + terminal // StoreAndForwardMessage.Id and emit per-attempt + terminal
// cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F // cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F
// mints its own GUID (legacy pre-M3 behaviour). // mints its own GUID (legacy pre-M3 behaviour).
messageId: trackedOperationId?.ToString()); messageId: trackedOperationId?.ToString(),
// Audit Log #23 (ExecutionId Task 4): thread the originating
// script execution's ExecutionId + SourceScript onto the
// buffered row so the retry-loop cached-call audit rows carry
// the same provenance the script-side cached rows do.
executionId: executionId,
sourceScript: sourceScript);
return new ExternalCallResult(true, null, null, WasBuffered: true); return new ExternalCallResult(true, null, null, WasBuffered: true);
} }
+46 -10
View File
@@ -370,6 +370,11 @@ akka {{
.WithSingletonName("audit-log-ingest")); .WithSingletonName("audit-log-ingest"));
var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy"); var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy");
// Hand the audit-ingest proxy to the CentralCommunicationActor so audit
// ingest commands forwarded by sites over ClusterClient are routed to the
// singleton. Mirrors the RegisterNotificationOutbox wiring above.
centralCommActor.Tell(new RegisterAuditIngest(auditIngestProxy));
// Hand the proxy to the SiteStreamGrpcServer (if registered on this node) // Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
// so the IngestAuditEvents RPC routes incoming site batches to the singleton. // so the IngestAuditEvents RPC routes incoming site batches to the singleton.
// The gRPC server is currently only registered on Site nodes; on a central // The gRPC server is currently only registered on Site nodes; on a central
@@ -410,18 +415,23 @@ akka {{
// and NotificationOutbox patterns. M3's dual-write transaction routes // and NotificationOutbox patterns. M3's dual-write transaction routes
// SiteCalls upserts through AuditLogIngestActor's own scope-per-message // SiteCalls upserts through AuditLogIngestActor's own scope-per-message
// ISiteCallAuditRepository resolution, so this singleton is not on the // ISiteCallAuditRepository resolution, so this singleton is not on the
// M3 happy-path hot path; it exists so future direct-write callers // M3 happy-path hot path; it exists so direct-write callers Ask through
// (reconciliation puller, central→site Retry/Discard relay, KPI // a stable cluster proxy without further wiring. The central→site
// projector) Ask through a stable cluster proxy without further wiring. // Retry/Discard relay now lives in this actor (see the
// RegisterCentralCommunication wiring below); the reconciliation puller
// is the remaining deferred direct-write caller.
// Like AuditLogIngestActor, the actor takes the root IServiceProvider // Like AuditLogIngestActor, the actor takes the root IServiceProvider
// and creates a fresh scope per message because ISiteCallAuditRepository // and creates a fresh scope per message because ISiteCallAuditRepository
// is a scoped EF Core service. // is a scoped EF Core service.
var siteCallAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>() var siteCallAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ScadaLink.SiteCallAudit.SiteCallAuditActor>(); .CreateLogger<ScadaLink.SiteCallAudit.SiteCallAuditActor>();
var siteCallAuditOptions = _serviceProvider
.GetRequiredService<IOptions<ScadaLink.SiteCallAudit.SiteCallAuditOptions>>().Value;
var siteCallAuditSingletonProps = ClusterSingletonManager.Props( var siteCallAuditSingletonProps = ClusterSingletonManager.Props(
singletonProps: Props.Create(() => new ScadaLink.SiteCallAudit.SiteCallAuditActor( singletonProps: Props.Create(() => new ScadaLink.SiteCallAudit.SiteCallAuditActor(
_serviceProvider, _serviceProvider,
siteCallAuditOptions,
siteCallAuditLogger)), siteCallAuditLogger)),
terminationMessage: PoisonPill.Instance, terminationMessage: PoisonPill.Instance,
settings: ClusterSingletonManagerSettings.Create(_actorSystem!) settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
@@ -432,8 +442,23 @@ akka {{
singletonManagerPath: "/user/site-call-audit-singleton", singletonManagerPath: "/user/site-call-audit-singleton",
settings: ClusterSingletonProxySettings.Create(_actorSystem) settings: ClusterSingletonProxySettings.Create(_actorSystem)
.WithSingletonName("site-call-audit")); .WithSingletonName("site-call-audit"));
_actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy"); var siteCallAuditProxy = _actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy");
_logger.LogInformation("SiteCallAuditActor singleton created");
// Hand the proxy to the CommunicationService so the Central UI can Ask
// the Site Call Audit actor directly (query, KPIs, detail) — mirrors the
// SetNotificationOutbox wiring above.
commService?.SetSiteCallAudit(siteCallAuditProxy);
// Task 5 (#22): hand the CentralCommunicationActor to the SiteCallAudit
// actor so it can relay operator Retry/Discard on parked cached calls to
// the owning site (over the per-site ClusterClient via SiteEnvelope).
// Mirrors the RegisterAuditIngest / RegisterNotificationOutbox wiring;
// the message is sent to the singleton proxy so it reaches whichever
// central node currently hosts the singleton.
siteCallAuditProxy.Tell(
new ScadaLink.SiteCallAudit.RegisterCentralCommunication(centralCommActor));
_logger.LogInformation(
"SiteCallAuditActor singleton created and registered with CentralCommunicationActor");
_logger.LogInformation("Central actors registered. CentralCommunicationActor created."); _logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
} }
@@ -656,15 +681,26 @@ akka {{
// Per Bundle E's brief: the SiteAuditTelemetryActor takes its // Per Bundle E's brief: the SiteAuditTelemetryActor takes its
// collaborators through its constructor, so we resolve them from DI // collaborators through its constructor, so we resolve them from DI
// and pass them in via Props.Create rather than relying on a future // and pass them in via Props.Create rather than relying on a future
// FactoryProvider. This also lets the M6 follow-up swap the // FactoryProvider. The real site→central client is constructed and
// NoOpSiteStreamAuditClient registration for the real gRPC client // wired immediately below: a ClusterClientSiteAuditClient (ClusterClient
// without touching this site wiring. // transport, not gRPC) replaces the DI-default NoOpSiteStreamAuditClient
// for site roles, without disturbing the rest of this wiring.
var siteAuditOptions = _serviceProvider var siteAuditOptions = _serviceProvider
.GetRequiredService<IOptions<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>(); .GetRequiredService<IOptions<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>();
var siteAuditQueue = _serviceProvider var siteAuditQueue = _serviceProvider
.GetRequiredService<ScadaLink.Commons.Interfaces.Services.ISiteAuditQueue>(); .GetRequiredService<ScadaLink.Commons.Interfaces.Services.ISiteAuditQueue>();
var siteAuditClient = _serviceProvider // Audit Log (#23) Task 2 follow-up: the production site→central audit
.GetRequiredService<ScadaLink.AuditLog.Site.Telemetry.ISiteStreamAuditClient>(); // push uses the ClusterClient transport via the SiteCommunicationActor,
// not the DI-resolved NoOpSiteStreamAuditClient. The NoOp default stays
// correct for central/test composition roots (no SiteCommunicationActor);
// a site role wires the real ClusterClient-based client here so the
// SQLite Pending backlog actually drains to central. The forward Ask
// reuses NotificationForwardTimeout — the same site→central command
// forward bound notifications already use over this transport.
ScadaLink.AuditLog.Site.Telemetry.ISiteStreamAuditClient siteAuditClient =
new ScadaLink.AuditLog.Site.Telemetry.ClusterClientSiteAuditClient(
siteCommActor,
_communicationOptions.NotificationForwardTimeout);
var siteAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>() var siteAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor>(); .CreateLogger<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor>();
+1
View File
@@ -77,6 +77,7 @@
</script> </script>
<script src="/js/treeview-storage.js"></script> <script src="/js/treeview-storage.js"></script>
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script> <script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
<script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>
@@ -145,6 +145,21 @@ public sealed class AuditWriteMiddleware
OccurredAtUtc = DateTime.UtcNow, OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound, Channel = AuditChannel.ApiInbound,
Kind = kind, Kind = kind,
// Audit Log #23: a fresh per-request execution id so the
// inbound row carries a request identifier (closes the design
// gap that inbound rows should be correlatable).
//
// This id is intentionally request-local: it is NOT bridged to
// RouteHelper's routed-call correlation id or to
// HttpContext.TraceIdentifier. Threading an inbound request's
// execution id through to the routed script execution (so an
// inbound call and the outbound API/DB rows it triggers share
// one id) is a deliberate future follow-up, out of scope here.
ExecutionId = Guid.NewGuid(),
// CorrelationId is purely the per-operation-lifecycle id; an
// inbound request is a one-shot from the audit row's
// perspective with no multi-row operation to correlate.
CorrelationId = null,
Actor = actor, Actor = actor,
Target = methodName, Target = methodName,
Status = status, Status = status,
@@ -367,32 +367,26 @@ public static class AuditEndpoints
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. Unknown /// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// enum names / un-parseable Guids / dates are silently dropped (no 400) — /// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
/// the same lax contract the CentralUI export endpoint uses. /// multi-value: a repeated query param (<c>channel=A&amp;channel=B</c>) yields
/// a multi-element filter list, while a single param yields a one-element
/// list. Unknown enum names / un-parseable Guids / dates are silently dropped
/// (no 400) — the same lax contract the CentralUI export endpoint uses; an
/// unparseable value within a repeated set is dropped, not the whole set.
/// </summary> /// </summary>
/// <remarks>
/// This endpoint reads the source-site filter from the <c>sourceSiteId</c>
/// query key, whereas the CentralUI export endpoint reads it as <c>site</c>.
/// The divergence is deliberate — each endpoint matches its own CLI / UI URL
/// builder — so do NOT "fix" the two to a single key name.
/// </remarks>
public static AuditLogQueryFilter ParseFilter(IQueryCollection query) public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{ {
AuditChannel? channel = null; var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
if (query.TryGetValue("channel", out var channelValues) var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
{ var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]);
channel = parsedChannel;
}
AuditKind? kind = null;
if (query.TryGetValue("kind", out var kindValues)
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
{
kind = parsedKind;
}
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
Guid? correlationId = null; Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues) if (query.TryGetValue("correlationId", out var corrValues)
@@ -401,14 +395,22 @@ public static class AuditEndpoints
correlationId = parsedCorr; correlationId = parsedCorr;
} }
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channel: channel, Channels: channels,
Kind: kind, Kinds: kinds,
Status: status, Statuses: statuses,
SourceSiteId: TrimToNullable(query, "sourceSiteId"), SourceSiteIds: sourceSiteIds,
Target: TrimToNullable(query, "target"), Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"), Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId, CorrelationId: correlationId,
ExecutionId: executionId,
FromUtc: ParseUtcDate(query, "fromUtc"), FromUtc: ParseUtcDate(query, "fromUtc"),
ToUtc: ParseUtcDate(query, "toUtc")); ToUtc: ParseUtcDate(query, "toUtc"));
} }
@@ -1124,6 +1124,10 @@ public class ManagementActor : ReceiveActor
config.Port = cmd.Port; config.Port = cmd.Port;
config.AuthType = cmd.AuthMode; config.AuthType = cmd.AuthMode;
config.FromAddress = cmd.FromAddress; config.FromAddress = cmd.FromAddress;
// Preserve-if-null: an update that omits TlsMode/Credentials leaves the
// existing values intact (non-breaking for callers that do not send them).
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
await repo.UpdateSmtpConfigurationAsync(config); await repo.UpdateSmtpConfigurationAsync(config);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config); await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config);
@@ -30,6 +30,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
private const int FallbackMaxRetries = 10; private const int FallbackMaxRetries = 10;
private static readonly TimeSpan FallbackRetryDelay = TimeSpan.FromMinutes(1); private static readonly TimeSpan FallbackRetryDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// Audit <c>Actor</c> stamped on central-dispatch (<c>NotifyDeliver</c>) rows.
/// The Actor-column spec assigns central-originated audit rows a system
/// identity — there is no per-call authenticated user at dispatch time.
/// </summary>
private const string SystemActor = "system";
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly NotificationOutboxOptions _options; private readonly NotificationOutboxOptions _options;
private readonly ICentralAuditWriter _auditWriter; private readonly ICentralAuditWriter _auditWriter;
@@ -66,6 +73,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
Receive<InternalMessages.PurgeComplete>(_ => { }); Receive<InternalMessages.PurgeComplete>(_ => { });
Receive<NotificationOutboxQueryRequest>(HandleQuery); Receive<NotificationOutboxQueryRequest>(HandleQuery);
Receive<NotificationStatusQuery>(HandleStatusQuery); Receive<NotificationStatusQuery>(HandleStatusQuery);
Receive<NotificationDetailRequest>(HandleDetailRequest);
Receive<RetryNotificationRequest>(HandleRetry); Receive<RetryNotificationRequest>(HandleRetry);
Receive<DiscardNotificationRequest>(HandleDiscard); Receive<DiscardNotificationRequest>(HandleDiscard);
Receive<NotificationKpiRequest>(HandleKpiRequest); Receive<NotificationKpiRequest>(HandleKpiRequest);
@@ -481,6 +489,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
/// parses the notification's id as a Guid; sites generate the id with /// parses the notification's id as a Guid; sites generate the id with
/// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but /// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but
/// a non-Guid id is recorded as null rather than crashing the dispatcher. /// a non-Guid id is recorded as null rather than crashing the dispatcher.
/// <see cref="AuditEvent.ExecutionId"/> is copied straight from
/// <see cref="Notification.OriginExecutionId"/> so the dispatcher's
/// <c>NotifyDeliver</c> rows carry the same per-run id as the site's
/// <c>NotifySend</c> row (Audit Log #23).
/// </summary> /// </summary>
private static AuditEvent BuildNotifyDeliverEvent( private static AuditEvent BuildNotifyDeliverEvent(
Notification notification, Notification notification,
@@ -499,12 +511,20 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
Channel = AuditChannel.Notification, Channel = AuditChannel.Notification,
Kind = AuditKind.NotifyDeliver, Kind = AuditKind.NotifyDeliver,
CorrelationId = correlationId, CorrelationId = correlationId,
// Central dispatch — no authenticated actor (the originating // Central dispatch — a system identity per the Actor-column spec;
// script's identity is captured on the upstream NotifySend row). // there is no per-call authenticated user here. The originating
Actor = null, // script is still captured on SourceScript (and on the upstream
// NotifySend row).
Actor = SystemActor,
SourceSiteId = notification.SourceSiteId, SourceSiteId = notification.SourceSiteId,
SourceInstanceId = notification.SourceInstanceId, SourceInstanceId = notification.SourceInstanceId,
SourceScript = notification.SourceScript, SourceScript = notification.SourceScript,
// ExecutionId (Audit Log #23): the originating script execution's id,
// carried from the site on NotificationSubmit and persisted on the
// Notification row. Echoing it here links the central NotifyDeliver
// rows to the site-emitted NotifySend row for the same run. Null when
// the notification was raised outside a script execution.
ExecutionId = notification.OriginExecutionId,
Target = notification.ListName, Target = notification.ListName,
Status = status, Status = status,
ErrorMessage = errorMessage, ErrorMessage = errorMessage,
@@ -674,6 +694,59 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
DeliveredAt: notification.DeliveredAt); DeliveredAt: notification.DeliveredAt);
} }
/// <summary>
/// Handles a full-detail query for a single notification — backs the report detail
/// modal, which needs the Body and resolved recipients that the grid summary omits.
/// </summary>
private void HandleDetailRequest(NotificationDetailRequest request)
{
var sender = Sender;
DetailAsync(request).PipeTo(
sender,
success: response => response,
failure: ex => new NotificationDetailResponse(
request.CorrelationId, Success: false,
ErrorMessage: ex.GetBaseException().Message, Detail: null));
}
private async Task<NotificationDetailResponse> DetailAsync(NotificationDetailRequest request)
{
using var scope = _serviceProvider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
var notification = await repository.GetByIdAsync(request.NotificationId);
if (notification is null)
{
return new NotificationDetailResponse(
request.CorrelationId, Success: false,
ErrorMessage: "notification not found", Detail: null);
}
var detail = new NotificationDetail(
notification.NotificationId,
notification.Type.ToString(),
notification.ListName,
notification.Subject,
notification.Body,
notification.Status.ToString(),
notification.RetryCount,
notification.LastError,
notification.ResolvedTargets,
notification.TypeData,
notification.SourceSiteId,
notification.SourceInstanceId,
notification.SourceScript,
notification.SiteEnqueuedAt,
notification.CreatedAt,
notification.LastAttemptAt,
notification.NextAttemptAt,
notification.DeliveredAt);
return new NotificationDetailResponse(
request.CorrelationId, Success: true, ErrorMessage: null, detail);
}
/// <summary> /// <summary>
/// Handles a manual retry request. Only a <c>Parked</c> notification can be retried; /// Handles a manual retry request. Only a <c>Parked</c> notification can be retried;
/// it is reset to <c>Pending</c> with a cleared retry count, next-attempt time, and /// it is reset to <c>Pending</c> with a cleared retry count, next-attempt time, and
@@ -878,6 +951,9 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
{ {
SourceInstanceId = msg.SourceInstanceId, SourceInstanceId = msg.SourceInstanceId,
SourceScript = msg.SourceScript, SourceScript = msg.SourceScript,
// OriginExecutionId (Audit Log #23): the originating script execution's id,
// carried from the site so the dispatcher can echo it onto NotifyDeliver rows.
OriginExecutionId = msg.OriginExecutionId,
SiteEnqueuedAt = msg.SiteEnqueuedAt, SiteEnqueuedAt = msg.SiteEnqueuedAt,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
// Status stays at its Pending default for the dispatch sweep to claim. // Status stays at its Pending default for the dispatch sweep to claim.
@@ -13,6 +13,8 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<!-- BindConfiguration extension for the SiteCallAuditOptions binding. -->
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -22,6 +24,11 @@
project reference is documented here so the actor's scope-per-message project reference is documented here so the actor's scope-per-message
GetRequiredService<ISiteCallAuditRepository>() compiles. --> GetRequiredService<ISiteCallAuditRepository>() compiles. -->
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" /> <ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
<!-- Task 5 (#22): the central→site Retry/Discard relay routes RetryParkedOperation /
DiscardParkedOperation to the owning site via SiteEnvelope + CentralCommunicationActor,
the same transport every other central→site command uses. SiteEnvelope is defined
in ScadaLink.Communication (no cycle: Communication does not reference SiteCallAudit). -->
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -7,33 +7,34 @@ namespace ScadaLink.SiteCallAudit;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// M3 Bundle C ships the ingest-only minimum surface (the actor itself); the /// Binds <see cref="SiteCallAuditOptions"/> (stuck-call detection + KPI
/// full DI surface — reconciliation puller, KPI projector, central→site /// windowing for the read-side query/KPI handlers). The reconciliation puller
/// Retry/Discard relay, options + validators — is deferred to a follow-up. /// and central→site Retry/Discard relay are still deferred to later follow-ups.
/// </para> /// </para>
/// <para> /// <para>
/// The repository (<c>ISiteCallAuditRepository</c>) is registered by /// The repository (<c>ISiteCallAuditRepository</c>) is registered by
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>, /// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so callers (the Host on the central node) must also call that. The actor's /// so callers (the Host on the central node) must also call that. The actor's
/// <c>Props</c> are wired up in Host registration (Bundle F); this extension /// <c>Props</c> are wired up in Host registration.
/// is currently a no-op placeholder kept for symmetry with the AuditLog and
/// NotificationOutbox composition roots — adding it now means consumers can
/// reference the method without re-touching the Host project later.
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
/// <summary>Configuration section bound to <see cref="SiteCallAuditOptions"/>.</summary>
public const string OptionsSection = "ScadaLink:SiteCallAudit";
/// <summary> /// <summary>
/// Registers Site Call Audit (#22) services. Currently a no-op /// Registers Site Call Audit (#22) services: the <see cref="SiteCallAuditOptions"/>
/// placeholder — Bundle F will populate this with the actor's Props /// binding consumed by the actor's read-side KPI/query handlers. The actor's
/// factory + options bindings. The method is exposed now so the Host /// <c>Props</c> are still constructed inline in Host wiring.
/// wiring call already exists at the API boundary.
/// </summary> /// </summary>
public static IServiceCollection AddSiteCallAudit(this IServiceCollection services) public static IServiceCollection AddSiteCallAudit(this IServiceCollection services)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
// Actor props are constructed in Host wiring (Bundle F). This
// extension is a placeholder for future config + DI. services.AddOptions<SiteCallAuditOptions>()
.BindConfiguration(OptionsSection);
return services; return services;
} }
} }
@@ -1,8 +1,13 @@
using Akka.Actor; using Akka.Actor;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit; using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Messages.RemoteQuery;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Communication;
namespace ScadaLink.SiteCallAudit; namespace ScadaLink.SiteCallAudit;
@@ -18,10 +23,10 @@ namespace ScadaLink.SiteCallAudit;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// M3 ships the minimum surface: ingest only. Reconciliation, KPIs, and /// Query, detail and KPIs (Task 4) and the central→site Retry/Discard relay
/// central→site Retry/Discard relay are deferred (per CLAUDE.md scope /// (Task 5 — the relay handlers live in this actor) are implemented; only
/// discipline — Site Call Audit's KPIs and the Retry/Discard relay land in a /// reconciliation remains deferred (per CLAUDE.md scope discipline — it lands
/// follow-up). /// in a later follow-up).
/// </para> /// </para>
/// <para> /// <para>
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" — /// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
@@ -42,26 +47,47 @@ namespace ScadaLink.SiteCallAudit;
/// </remarks> /// </remarks>
public class SiteCallAuditActor : ReceiveActor public class SiteCallAuditActor : ReceiveActor
{ {
/// <summary>Maximum page size honoured by a <see cref="SiteCallQueryRequest"/>.</summary>
private const int MaxPageSize = 200;
private readonly IServiceProvider? _serviceProvider; private readonly IServiceProvider? _serviceProvider;
private readonly ISiteCallAuditRepository? _injectedRepository; private readonly ISiteCallAuditRepository? _injectedRepository;
private readonly SiteCallAuditOptions _options;
private readonly ILogger<SiteCallAuditActor> _logger; private readonly ILogger<SiteCallAuditActor> _logger;
/// <summary>
/// Task 5 (#22): the central→site command transport — the
/// <c>CentralCommunicationActor</c>, which owns the per-site
/// <c>ClusterClient</c> map and routes a <see cref="SiteEnvelope"/> to the
/// owning site. Set via <see cref="RegisterCentralCommunication"/> by the
/// Host after both actors exist (this actor is a cluster singleton; the
/// transport actor is created separately). Null until registration
/// completes — a relay arriving before then is answered with a
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome, because there
/// is genuinely no route to any site yet.
/// </summary>
private IActorRef? _centralCommunication;
/// <summary> /// <summary>
/// Test-mode constructor — injects a concrete repository instance whose /// Test-mode constructor — injects a concrete repository instance whose
/// lifetime exceeds the test, so the actor reuses the same instance /// lifetime exceeds the test, so the actor reuses the same instance
/// across every message. Used by Bundle C's MSSQL-backed TestKit fixture. /// across every message. Used by Bundle C's MSSQL-backed TestKit fixture.
/// An optional <paramref name="options"/> lets a test pin the stuck/KPI
/// windows; when omitted the production defaults apply.
/// </summary> /// </summary>
public SiteCallAuditActor( public SiteCallAuditActor(
ISiteCallAuditRepository repository, ISiteCallAuditRepository repository,
ILogger<SiteCallAuditActor> logger) ILogger<SiteCallAuditActor> logger,
SiteCallAuditOptions? options = null)
{ {
ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
_injectedRepository = repository; _injectedRepository = repository;
_logger = logger; _logger = logger;
_options = options ?? new SiteCallAuditOptions();
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync); RegisterHandlers();
} }
/// <summary> /// <summary>
@@ -73,15 +99,42 @@ public class SiteCallAuditActor : ReceiveActor
/// </summary> /// </summary>
public SiteCallAuditActor( public SiteCallAuditActor(
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
SiteCallAuditOptions options,
ILogger<SiteCallAuditActor> logger) ILogger<SiteCallAuditActor> logger)
{ {
ArgumentNullException.ThrowIfNull(serviceProvider); ArgumentNullException.ThrowIfNull(serviceProvider);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_options = options;
_logger = logger; _logger = logger;
RegisterHandlers();
}
/// <summary>
/// Wires up the message handlers shared by both constructors: the M3
/// ingest path plus the Task 4 read-side (query, detail, global + per-site
/// KPI). All read handlers reply to an Ask, so they capture <c>Sender</c>
/// before the first await and <c>PipeTo</c> the result back.
/// </summary>
private void RegisterHandlers()
{
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync); ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
Receive<SiteCallQueryRequest>(HandleQuery);
Receive<SiteCallDetailRequest>(HandleDetail);
Receive<SiteCallKpiRequest>(HandleKpi);
Receive<PerSiteSiteCallKpiRequest>(HandlePerSiteKpi);
// Task 5 (#22): central→site Retry/Discard relay for parked cached calls.
Receive<RegisterCentralCommunication>(msg =>
{
_centralCommunication = msg.CentralCommunication;
_logger.LogInformation("SiteCallAudit registered central→site communication transport");
});
Receive<RetrySiteCallRequest>(HandleRetrySiteCall);
Receive<DiscardSiteCallRequest>(HandleDiscardSiteCall);
} }
/// <summary> /// <summary>
@@ -137,4 +190,486 @@ public class SiteCallAuditActor : ReceiveActor
scope?.Dispose(); scope?.Dispose();
} }
} }
// ── Task 4: read-side (query / detail / KPI) ──
/// <summary>
/// Handles a paginated, filtered query over the <c>SiteCalls</c> table.
/// Builds a <see cref="SiteCallQueryFilter"/> + <see cref="SiteCallPaging"/>
/// keyset cursor from the request, runs the query on a scoped repository,
/// and pipes the mapped response back to the captured sender. A repository
/// fault yields a failure response with an empty list.
/// </summary>
private void HandleQuery(SiteCallQueryRequest request)
{
var sender = Sender;
var now = DateTime.UtcNow;
QueryAsync(request, now).PipeTo(
sender,
success: response => response,
failure: ex => new SiteCallQueryResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
SiteCalls: Array.Empty<SiteCallSummary>(),
NextAfterCreatedAtUtc: null,
NextAfterId: null));
}
private async Task<SiteCallQueryResponse> QueryAsync(SiteCallQueryRequest request, DateTime now)
{
var stuckCutoff = now - _options.StuckAgeThreshold;
var filter = new SiteCallQueryFilter(
Channel: NullIfBlank(request.ChannelFilter),
SourceSite: NullIfBlank(request.SourceSiteFilter),
Status: NullIfBlank(request.StatusFilter),
Target: NullIfBlank(request.TargetKeyword),
FromUtc: request.FromUtc,
ToUtc: request.ToUtc,
// StuckOnly is pushed into the repository SQL via StuckCutoffUtc —
// TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the
// keyset cursor, so the page is always honest (full pages, no empty
// pages with a non-null next cursor).
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null);
var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
var paging = new SiteCallPaging(
PageSize: pageSize,
AfterCreatedAtUtc: request.AfterCreatedAtUtc,
AfterId: request.AfterId is { } id ? new TrackedOperationId(id) : null);
var (scope, repository) = ResolveRepository();
try
{
var rows = await repository.QueryAsync(filter, paging).ConfigureAwait(false);
var summaries = rows
.Select(row => ToSummary(row, stuckCutoff))
.ToList();
// The next-page cursor is the last row of the materialised page.
var cursorRow = rows.Count > 0 ? rows[^1] : null;
return new SiteCallQueryResponse(
request.CorrelationId,
Success: true,
ErrorMessage: null,
SiteCalls: summaries,
NextAfterCreatedAtUtc: cursorRow?.CreatedAtUtc,
NextAfterId: cursorRow?.TrackedOperationId.Value);
}
finally
{
scope?.Dispose();
}
}
/// <summary>
/// Handles a full-detail query for a single cached call — backs the report
/// detail modal. A missing row yields <c>Success=false</c> with a "not
/// found" message; a repository fault yields <c>Success=false</c> with the
/// fault message.
/// </summary>
private void HandleDetail(SiteCallDetailRequest request)
{
var sender = Sender;
DetailAsync(request).PipeTo(
sender,
success: response => response,
failure: ex => new SiteCallDetailResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
Detail: null));
}
private async Task<SiteCallDetailResponse> DetailAsync(SiteCallDetailRequest request)
{
var (scope, repository) = ResolveRepository();
try
{
var row = await repository
.GetAsync(new TrackedOperationId(request.TrackedOperationId))
.ConfigureAwait(false);
if (row is null)
{
return new SiteCallDetailResponse(
request.CorrelationId,
Success: false,
ErrorMessage: "site call not found",
Detail: null);
}
return new SiteCallDetailResponse(
request.CorrelationId,
Success: true,
ErrorMessage: null,
Detail: ToDetail(row));
}
finally
{
scope?.Dispose();
}
}
/// <summary>
/// Handles a global KPI snapshot request, deriving the stuck cutoff from
/// <see cref="SiteCallAuditOptions.StuckAgeThreshold"/> and the
/// failed/delivered interval bound from <see cref="SiteCallAuditOptions.KpiInterval"/>.
/// </summary>
private void HandleKpi(SiteCallKpiRequest request)
{
var sender = Sender;
var now = DateTime.UtcNow;
var stuckCutoff = now - _options.StuckAgeThreshold;
var intervalSince = now - _options.KpiInterval;
KpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
sender,
success: response => response,
failure: ex => new SiteCallKpiResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
BufferedCount: 0,
ParkedCount: 0,
FailedLastInterval: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null,
StuckCount: 0));
}
private async Task<SiteCallKpiResponse> KpiAsync(
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
{
var (scope, repository) = ResolveRepository();
try
{
var snapshot = await repository
.ComputeKpisAsync(stuckCutoff, intervalSince)
.ConfigureAwait(false);
return new SiteCallKpiResponse(
correlationId,
Success: true,
ErrorMessage: null,
snapshot.BufferedCount,
snapshot.ParkedCount,
snapshot.FailedLastInterval,
snapshot.DeliveredLastInterval,
snapshot.OldestPendingAge,
snapshot.StuckCount);
}
finally
{
scope?.Dispose();
}
}
/// <summary>
/// Handles a per-source-site KPI request, using the same stuck cutoff and
/// interval bound as <see cref="HandleKpi"/>.
/// </summary>
private void HandlePerSiteKpi(PerSiteSiteCallKpiRequest request)
{
var sender = Sender;
var now = DateTime.UtcNow;
var stuckCutoff = now - _options.StuckAgeThreshold;
var intervalSince = now - _options.KpiInterval;
PerSiteKpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
sender,
success: response => response,
failure: ex => new PerSiteSiteCallKpiResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
Sites: Array.Empty<SiteCallSiteKpiSnapshot>()));
}
private async Task<PerSiteSiteCallKpiResponse> PerSiteKpiAsync(
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
{
var (scope, repository) = ResolveRepository();
try
{
var sites = await repository
.ComputePerSiteKpisAsync(stuckCutoff, intervalSince)
.ConfigureAwait(false);
return new PerSiteSiteCallKpiResponse(
correlationId, Success: true, ErrorMessage: null, sites);
}
finally
{
scope?.Dispose();
}
}
// ── Task 5: central→site Retry/Discard relay ──
/// <summary>
/// Relays an operator Retry of a parked cached call to its owning site. The
/// site is the source of truth — this handler NEVER writes the central
/// <c>SiteCalls</c> mirror row. It wraps a <see cref="RetryParkedOperation"/>
/// in a <see cref="SiteEnvelope"/> addressed to <c>SourceSite</c>, Asks the
/// <c>CentralCommunicationActor</c> (which routes it over the per-site
/// <c>ClusterClient</c>), and maps the site's
/// <see cref="ParkedOperationActionAck"/> — or an Ask timeout — onto a
/// <see cref="RetrySiteCallResponse"/>. A timeout / no-route is reported as
/// the distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome,
/// not a generic failure, so the Central UI can tell "site offline" from
/// "operation failed".
/// </summary>
private void HandleRetrySiteCall(RetrySiteCallRequest request)
{
var sender = Sender;
if (_centralCommunication is null)
{
// No transport registered yet — there is genuinely no route to any
// site, so the only honest answer is unreachable.
_logger.LogWarning(
"RetrySiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
+ "central→site transport was registered; reporting site unreachable",
request.TrackedOperationId, request.SourceSite);
sender.Tell(UnreachableRetry(request.CorrelationId));
return;
}
var relay = new RetryParkedOperation(
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
var envelope = new SiteEnvelope(request.SourceSite, relay);
_centralCommunication.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
.PipeTo(
sender,
success: ack => MapRetryResponse(request.CorrelationId, ack),
failure: ex => MapRetryFailure(request.CorrelationId, request.SourceSite, ex));
}
/// <summary>
/// Relays an operator Discard of a parked cached call to its owning site.
/// Mirrors <see cref="HandleRetrySiteCall"/> — see that method for the
/// source-of-truth and site-unreachable rationale.
/// </summary>
private void HandleDiscardSiteCall(DiscardSiteCallRequest request)
{
var sender = Sender;
if (_centralCommunication is null)
{
_logger.LogWarning(
"DiscardSiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
+ "central→site transport was registered; reporting site unreachable",
request.TrackedOperationId, request.SourceSite);
sender.Tell(UnreachableDiscard(request.CorrelationId));
return;
}
var relay = new DiscardParkedOperation(
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
var envelope = new SiteEnvelope(request.SourceSite, relay);
_centralCommunication.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
.PipeTo(
sender,
success: ack => MapDiscardResponse(request.CorrelationId, ack),
failure: ex => MapDiscardFailure(request.CorrelationId, request.SourceSite, ex));
}
/// <summary>
/// Maps the site's <see cref="ParkedOperationActionAck"/> for a Retry onto a
/// <see cref="RetrySiteCallResponse"/>: an applied action is
/// <see cref="SiteCallRelayOutcome.Applied"/>; a clean no-op
/// (<c>Applied=false</c>, no error) is <see cref="SiteCallRelayOutcome.NotParked"/>;
/// an ack carrying an error is <see cref="SiteCallRelayOutcome.OperationFailed"/>
/// — in every case the site WAS reached.
/// </summary>
private static RetrySiteCallResponse MapRetryResponse(string correlationId, ParkedOperationActionAck ack)
{
var outcome = ClassifyAck(ack);
return new RetrySiteCallResponse(
correlationId,
outcome,
Success: outcome == SiteCallRelayOutcome.Applied,
SiteReachable: true,
ErrorMessage: AckErrorMessage(outcome, ack));
}
private static DiscardSiteCallResponse MapDiscardResponse(string correlationId, ParkedOperationActionAck ack)
{
var outcome = ClassifyAck(ack);
return new DiscardSiteCallResponse(
correlationId,
outcome,
Success: outcome == SiteCallRelayOutcome.Applied,
SiteReachable: true,
ErrorMessage: AckErrorMessage(outcome, ack));
}
private RetrySiteCallResponse MapRetryFailure(string correlationId, string sourceSite, Exception ex)
{
_logger.LogWarning(ex,
"Retry relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
return UnreachableRetry(correlationId);
}
private DiscardSiteCallResponse MapDiscardFailure(string correlationId, string sourceSite, Exception ex)
{
_logger.LogWarning(ex,
"Discard relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
return UnreachableDiscard(correlationId);
}
/// <summary>
/// Classifies a site ack: <c>Applied=true</c> → applied; <c>Applied=false</c>
/// with no error → the site definitively had nothing parked; <c>Applied=false</c>
/// with an error → the site could not apply the action.
/// </summary>
private static SiteCallRelayOutcome ClassifyAck(ParkedOperationActionAck ack)
{
if (ack.Applied)
{
return SiteCallRelayOutcome.Applied;
}
return ack.ErrorMessage is null
? SiteCallRelayOutcome.NotParked
: SiteCallRelayOutcome.OperationFailed;
}
private static string? AckErrorMessage(SiteCallRelayOutcome outcome, ParkedOperationActionAck ack)
{
return outcome switch
{
SiteCallRelayOutcome.Applied => null,
SiteCallRelayOutcome.NotParked =>
"The operation is no longer parked at the site (already delivered, discarded, or retrying).",
SiteCallRelayOutcome.OperationFailed => ack.ErrorMessage,
// SiteUnreachable is never produced from a ParkedOperationActionAck —
// unreachable responses are built by UnreachableRetry/UnreachableDiscard
// before any ack is classified, so this arm is unreachable by construction.
SiteCallRelayOutcome.SiteUnreachable => ack.ErrorMessage,
_ => throw new ArgumentOutOfRangeException(
nameof(outcome), outcome, "unknown SiteCallRelayOutcome"),
};
}
/// <summary>Shared "site unreachable" detail text for both relay directions.</summary>
private const string SiteUnreachableMessage =
"The owning site is unreachable; the action was not applied. Retry when the site is back online.";
private static RetrySiteCallResponse UnreachableRetry(string correlationId)
{
return new RetrySiteCallResponse(
correlationId,
SiteCallRelayOutcome.SiteUnreachable,
Success: false,
SiteReachable: false,
ErrorMessage: SiteUnreachableMessage);
}
private static DiscardSiteCallResponse UnreachableDiscard(string correlationId)
{
return new DiscardSiteCallResponse(
correlationId,
SiteCallRelayOutcome.SiteUnreachable,
Success: false,
SiteReachable: false,
ErrorMessage: SiteUnreachableMessage);
}
/// <summary>
/// Resolves an <see cref="ISiteCallAuditRepository"/> for one read message.
/// In test mode the injected instance is returned with a null scope; in
/// production a fresh DI scope is created and returned so the caller can
/// dispose it once the read completes — the same scope-per-message pattern
/// as <see cref="OnUpsertAsync"/>.
/// </summary>
private (IServiceScope? Scope, ISiteCallAuditRepository Repository) ResolveRepository()
{
if (_injectedRepository is not null)
{
return (null, _injectedRepository);
}
var scope = _serviceProvider!.CreateScope();
return (scope, scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>());
}
/// <summary>
/// A cached call counts as stuck when it is still non-terminal and was
/// created before <paramref name="stuckCutoff"/>. Non-terminal is keyed off
/// <see cref="SiteCall.TerminalAtUtc"/> being <c>null</c> — the
/// <c>SiteCalls</c> operational mirror stores <c>AuditStatus</c>-derived
/// status strings (<c>Attempted</c>/<c>Delivered</c>/<c>Parked</c>/...), not
/// the tracking-lifecycle <c>Pending</c>/<c>Retrying</c> names the spec's
/// KPI section uses, so there is no status string that means "buffered".
/// <c>TerminalAtUtc</c> is the entity's own active/terminal discriminator
/// and is consistent with the repository KPI counts and
/// <c>PurgeTerminalAsync</c>.
/// </summary>
private static bool IsStuck(SiteCall row, DateTime stuckCutoff)
{
return row.TerminalAtUtc is null && row.CreatedAtUtc < stuckCutoff;
}
private static SiteCallSummary ToSummary(SiteCall row, DateTime stuckCutoff)
{
return new SiteCallSummary(
TrackedOperationId: row.TrackedOperationId.Value,
SourceSite: row.SourceSite,
Channel: row.Channel,
Target: row.Target,
Status: row.Status,
RetryCount: row.RetryCount,
LastError: row.LastError,
HttpStatus: row.HttpStatus,
CreatedAtUtc: row.CreatedAtUtc,
UpdatedAtUtc: row.UpdatedAtUtc,
TerminalAtUtc: row.TerminalAtUtc,
IsStuck: IsStuck(row, stuckCutoff));
}
private static SiteCallDetail ToDetail(SiteCall row)
{
return new SiteCallDetail(
TrackedOperationId: row.TrackedOperationId.Value,
SourceSite: row.SourceSite,
Channel: row.Channel,
Target: row.Target,
Status: row.Status,
RetryCount: row.RetryCount,
LastError: row.LastError,
HttpStatus: row.HttpStatus,
CreatedAtUtc: row.CreatedAtUtc,
UpdatedAtUtc: row.UpdatedAtUtc,
TerminalAtUtc: row.TerminalAtUtc,
IngestedAtUtc: row.IngestedAtUtc);
}
/// <summary>
/// Treats an empty/whitespace filter string as "no constraint" — the
/// repository's <see cref="SiteCallQueryFilter"/> interprets <c>null</c> as
/// a no-op predicate, so a blank UI filter must collapse to <c>null</c>.
/// </summary>
private static string? NullIfBlank(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
} }
/// <summary>
/// Registers the central→site command transport (the <c>CentralCommunicationActor</c>)
/// with the <see cref="SiteCallAuditActor"/> so it can relay Retry/Discard
/// actions on parked cached calls to their owning sites. Sent by the Host after
/// both actors exist. Lives here (not in Commons) because it carries an
/// <see cref="IActorRef"/> and <c>ScadaLink.Commons</c> has no Akka reference —
/// the same rationale as <c>RegisterAuditIngest</c>.
/// </summary>
public sealed record RegisterCentralCommunication(IActorRef CentralCommunication);
@@ -0,0 +1,47 @@
namespace ScadaLink.SiteCallAudit;
/// <summary>
/// Configuration options for the Site Call Audit (#22) read-side: stuck-call
/// detection and KPI windowing. Mirrors the KPI-relevant subset of
/// <c>NotificationOutboxOptions</c> — the reconciliation, purge and dispatch
/// cadence options the Notification Outbox carries are not part of the Site
/// Call Audit read-side backend and are deliberately omitted here.
/// </summary>
public class SiteCallAuditOptions
{
/// <summary>
/// Age past which a non-terminal cached call (<c>Pending</c>/<c>Retrying</c>)
/// is considered stuck. Display-only — surfaced as the Stuck KPI and a row
/// badge, with no escalation. Default 10 minutes, matching
/// <c>NotificationOutboxOptions.StuckAgeThreshold</c>.
/// </summary>
public TimeSpan StuckAgeThreshold { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Trailing window used to compute the delivered- and failed-last-interval
/// throughput KPIs. Default 1 minute, matching
/// <c>NotificationOutboxOptions.DeliveredKpiWindow</c>.
/// </summary>
public TimeSpan KpiInterval { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Task 5 (#22): Ask timeout for the central→site Retry/Discard relay. When
/// the owning site does not ack a <c>RetryParkedOperation</c> /
/// <c>DiscardParkedOperation</c> within this window — site offline, no
/// ClusterClient route, or central buffering deliberately absent — the relay
/// reports a <c>SiteUnreachable</c> outcome. Default 10 seconds: long enough
/// to absorb a healthy cross-cluster round-trip, short enough that an
/// operator clicking Retry on an offline site gets a fast, honest answer.
/// <para>
/// <b>Ordering invariant:</b> <c>RelayTimeout</c> must stay below
/// <c>CommunicationOptions.QueryTimeout</c> (default 30s), the timeout the
/// outer <c>CommunicationService.RetrySiteCallAsync</c>/<c>DiscardSiteCallAsync</c>
/// Ask of the <c>SiteCallAuditActor</c> uses. The outer Ask must outlive this
/// inner site relay Ask so the inner relay times out first and yields the
/// distinct <c>SiteUnreachable</c> outcome; if the outer Ask expired first,
/// that outcome would be lost to a generic Ask-timeout exception. The
/// defaults (10s &lt; 30s) satisfy this — keep the gap when tuning either.
/// </para>
/// </summary>
public TimeSpan RelayTimeout { get; set; } = TimeSpan.FromSeconds(10);
}
@@ -37,9 +37,13 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly Guid _executionId;
private readonly ILogger _logger; private readonly ILogger _logger;
private DbConnection? _wrappingConnection; private DbConnection? _wrappingConnection;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection).
public AuditingDbCommand( public AuditingDbCommand(
DbCommand inner, DbCommand inner,
IAuditWriter auditWriter, IAuditWriter auditWriter,
@@ -47,7 +51,8 @@ internal sealed class AuditingDbCommand : DbCommand
string siteId, string siteId,
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger) ILogger logger,
Guid executionId)
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -56,6 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand
_instanceName = instanceName ?? string.Empty; _instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
} }
// -- Forwarded surface ------------------------------------------------ // -- Forwarded surface ------------------------------------------------
@@ -426,11 +432,19 @@ internal sealed class AuditingDbCommand : DbCommand
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound, Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite, Kind = AuditKind.DbWrite,
// Audit Log #23: a sync one-shot DB write has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so this row shares an id with the other sync
// trust-boundary rows from the same script run.
CorrelationId = null, CorrelationId = null,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
Actor = null, // Outbound channel: per the Audit Log Actor-column spec the actor is
// the calling script. Null when no single script owns the call
// (e.g. a shared script running inline).
Actor = _sourceScript,
Target = target, Target = target,
Status = status, Status = status,
HttpStatus = null, HttpStatus = null,
@@ -36,8 +36,12 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly Guid _executionId;
private readonly ILogger _logger; private readonly ILogger _logger;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbCommand).
public AuditingDbConnection( public AuditingDbConnection(
DbConnection inner, DbConnection inner,
IAuditWriter auditWriter, IAuditWriter auditWriter,
@@ -45,7 +49,8 @@ internal sealed class AuditingDbConnection : DbConnection
string siteId, string siteId,
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger) ILogger logger,
Guid executionId)
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -54,6 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName = instanceName ?? string.Empty; _instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
} }
// ConnectionString is settable on DbConnection — forward both halves. // ConnectionString is settable on DbConnection — forward both halves.
@@ -92,7 +98,8 @@ internal sealed class AuditingDbConnection : DbConnection
_siteId, _siteId,
_instanceName, _instanceName,
_sourceScript, _sourceScript,
_logger); _logger,
_executionId);
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
@@ -105,6 +105,24 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// Audit Log #23: the per-execution id for this script run. Every
/// trust-boundary audit row emitted by this script execution
/// (sync <c>ApiCall</c>/<c>DbWrite</c>, cached-call lifecycle rows,
/// <c>NotifySend</c>) is stamped into <c>AuditEvent.ExecutionId</c> with
/// this value so all the rows from one script run can be correlated
/// together — independently of the per-operation
/// <c>AuditEvent.CorrelationId</c>.
/// </summary>
private readonly Guid _executionId;
/// <param name="executionId">
/// Audit Log #23: the per-execution id for this script run. When omitted
/// (tag-change / timer-triggered executions) a fresh id is generated; an
/// inbound caller may supply one to tie the execution to an upstream
/// request. Stamped into <c>AuditEvent.ExecutionId</c> on every
/// trust-boundary audit row this execution emits.
/// </param>
public ScriptRuntimeContext( public ScriptRuntimeContext(
IActorRef instanceActor, IActorRef instanceActor,
IActorRef self, IActorRef self,
@@ -122,7 +140,8 @@ public class ScriptRuntimeContext
string? sourceScript = null, string? sourceScript = null,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null, IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null) ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? executionId = null)
{ {
_instanceActor = instanceActor; _instanceActor = instanceActor;
_self = self; _self = self;
@@ -141,6 +160,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter; _auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore; _operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder; _cachedForwarder = cachedForwarder;
_executionId = executionId ?? Guid.NewGuid();
} }
/// <summary> /// <summary>
@@ -241,7 +261,7 @@ public class ScriptRuntimeContext
/// ExternalSystem.CachedCall("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary> /// </summary>
public ExternalSystemHelper ExternalSystem => new( public ExternalSystemHelper ExternalSystem => new(
_externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript, _externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue. // on every ExternalSystem.CachedCall enqueue.
_cachedForwarder); _cachedForwarder);
@@ -255,6 +275,7 @@ public class ScriptRuntimeContext
_databaseGateway, _databaseGateway,
_instanceName, _instanceName,
_logger, _logger,
_executionId,
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so // Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
// Database.Connection(name) returns an auditing decorator that // Database.Connection(name) returns an auditing decorator that
// emits one DbOutbound/DbWrite row per script-initiated // emits one DbOutbound/DbWrite row per script-initiated
@@ -281,7 +302,7 @@ public class ScriptRuntimeContext
/// </remarks> /// </remarks>
public NotifyHelper Notify => new( public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger, _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
_auditWriter); _executionId, _auditWriter);
/// <summary> /// <summary>
/// Audit Log #23 (M3): site-local tracking-status API for cached operations. /// Audit Log #23 (M3): site-local tracking-status API for cached operations.
@@ -362,6 +383,7 @@ public class ScriptRuntimeContext
private readonly IExternalSystemClient? _client; private readonly IExternalSystemClient? _client;
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Guid _executionId;
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
@@ -370,10 +392,18 @@ public class ScriptRuntimeContext
// Internal constructor for tests living in ScadaLink.SiteRuntime.Tests // Internal constructor for tests living in ScadaLink.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through // (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem. // ScriptRuntimeContext.ExternalSystem.
//
// Parameter ordering: executionId sits immediately after the
// ILogger across all four audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required
// Guid cannot follow the optional provenance params without a
// required-after-optional compile error, so the post-logger slot is the
// one consistent position that compiles cleanly everywhere.
internal ExternalSystemHelper( internal ExternalSystemHelper(
IExternalSystemClient? client, IExternalSystemClient? client,
string instanceName, string instanceName,
ILogger logger, ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
@@ -382,6 +412,7 @@ public class ScriptRuntimeContext
_client = client; _client = client;
_instanceName = instanceName; _instanceName = instanceName;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
@@ -420,7 +451,7 @@ public class ScriptRuntimeContext
{ {
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks) var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
* 1000d / Stopwatch.Frequency); * 1000d / Stopwatch.Frequency);
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown); EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters);
} }
} }
@@ -458,7 +489,7 @@ public class ScriptRuntimeContext
// Submitted row even if the immediate-delivery attempt happens to // Submitted row even if the immediate-delivery attempt happens to
// resolve before this method returns. // resolve before this method returns.
await EmitCachedSubmitTelemetryAsync( await EmitCachedSubmitTelemetryAsync(
systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken) systemName, methodName, target, trackedId, occurredAtUtc, parameters, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// Hand off to the existing cached-call path. The TrackedOperationId // Hand off to the existing cached-call path. The TrackedOperationId
@@ -482,7 +513,12 @@ public class ScriptRuntimeContext
parameters, parameters,
_instanceName, _instanceName,
cancellationToken, cancellationToken,
trackedId).ConfigureAwait(false); trackedId,
// Audit Log #23 (ExecutionId Task 4): thread the script
// execution's ExecutionId + SourceScript so a buffered
// cached call's retry-loop audit rows carry them.
executionId: _executionId,
sourceScript: _sourceScript).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -503,7 +539,7 @@ public class ScriptRuntimeContext
if (result is { WasBuffered: false }) if (result is { WasBuffered: false })
{ {
await EmitImmediateTerminalTelemetryAsync( await EmitImmediateTerminalTelemetryAsync(
systemName, methodName, target, trackedId, result, cancellationToken) systemName, methodName, target, trackedId, result, parameters, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
@@ -521,6 +557,7 @@ public class ScriptRuntimeContext
string target, string target,
TrackedOperationId trackedId, TrackedOperationId trackedId,
DateTime occurredAtUtc, DateTime occurredAtUtc,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (_cachedForwarder == null) if (_cachedForwarder == null)
@@ -538,12 +575,18 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit, Kind = AuditKind.CachedSubmit,
// CorrelationId stays the per-operation lifecycle id
// (TrackedOperationId); ExecutionId carries the
// per-execution id shared across this script run.
CorrelationId = trackedId.Value, CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
Target = target, Target = target,
Status = AuditStatus.Submitted, Status = AuditStatus.Submitted,
// Submit precedes the call — request args only, no response yet.
RequestSummary = SerializeRequest(parameters),
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -599,6 +642,7 @@ public class ScriptRuntimeContext
string target, string target,
TrackedOperationId trackedId, TrackedOperationId trackedId,
ExternalCallResult result, ExternalCallResult result,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (_cachedForwarder == null) if (_cachedForwarder == null)
@@ -645,7 +689,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached, Kind = AuditKind.ApiCallCached,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value, CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -653,6 +700,8 @@ public class ScriptRuntimeContext
Status = AuditStatus.Attempted, Status = AuditStatus.Attempted,
HttpStatus = httpStatus, HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage, ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -704,7 +753,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve, Kind = AuditKind.CachedResolve,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value, CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -712,6 +764,8 @@ public class ScriptRuntimeContext
Status = auditTerminalStatus, Status = auditTerminalStatus,
HttpStatus = httpStatus, HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage, ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}, },
Operational: new SiteCallOperational( Operational: new SiteCallOperational(
@@ -762,7 +816,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc, DateTime occurredAtUtc,
int durationMs, int durationMs,
ExternalCallResult? result, ExternalCallResult? result,
Exception? thrown) Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{ {
if (_auditWriter == null) if (_auditWriter == null)
{ {
@@ -772,7 +827,8 @@ public class ScriptRuntimeContext
AuditEvent evt; AuditEvent evt;
try try
{ {
evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown); evt = BuildCallAuditEvent(
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
} }
catch (Exception buildEx) catch (Exception buildEx)
{ {
@@ -828,7 +884,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc, DateTime occurredAtUtc,
int durationMs, int durationMs,
ExternalCallResult? result, ExternalCallResult? result,
Exception? thrown) Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{ {
// Status: Delivered on a Success result; Failed otherwise (the // Status: Delivered on a Success result; Failed otherwise (the
// ExternalSystemClient already maps HTTP non-2xx + transient // ExternalSystemClient already maps HTTP non-2xx + transient
@@ -871,24 +928,60 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall, Kind = AuditKind.ApiCall,
// Audit Log #23: a sync one-shot call has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so all the sync ApiCall/DbWrite rows from
// one script run can be correlated together.
CorrelationId = null, CorrelationId = null,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
Actor = null, // Outbound channel: per the Audit Log Actor-column spec the actor
// is the calling script. Null when no single script owns the call
// (e.g. a shared script running inline).
Actor = _sourceScript,
Target = $"{systemName}.{methodName}", Target = $"{systemName}.{methodName}",
Status = status, Status = status,
HttpStatus = httpStatus, HttpStatus = httpStatus,
DurationMs = durationMs, DurationMs = durationMs,
ErrorMessage = errorMessage, ErrorMessage = errorMessage,
ErrorDetail = errorDetail, ErrorDetail = errorDetail,
RequestSummary = null, // Payload capture: the request arguments and the response body.
ResponseSummary = null, // The audit writer's payload filter applies the configured size
// cap and header/secret redaction downstream — the emitter just
// hands over the raw values.
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result?.ResponseJson,
PayloadTruncated = false, PayloadTruncated = false,
Extra = null, Extra = null,
ForwardState = AuditForwardState.Pending, ForwardState = AuditForwardState.Pending,
}; };
} }
/// <summary>
/// Serialises the outbound-call argument dictionary into the JSON
/// <c>RequestSummary</c> stamped on <c>ApiOutbound</c> audit rows.
/// Returns <c>null</c> for a null/empty argument set. Serialization
/// failure is swallowed (returns <c>null</c>) — a payload that cannot be
/// summarised must never abort the best-effort audit emission.
/// </summary>
private static string? SerializeRequest(IReadOnlyDictionary<string, object?>? parameters)
{
if (parameters is null || parameters.Count == 0)
{
return null;
}
try
{
return JsonSerializer.Serialize(parameters);
}
catch (Exception)
{
return null;
}
}
} }
/// <summary> /// <summary>
@@ -907,6 +1000,7 @@ public class ScriptRuntimeContext
private readonly IDatabaseGateway? _gateway; private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Guid _executionId;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -923,10 +1017,15 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four
// audit-threaded ctors.
internal DatabaseHelper( internal DatabaseHelper(
IDatabaseGateway? gateway, IDatabaseGateway? gateway,
string instanceName, string instanceName,
ILogger logger, ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
@@ -935,6 +1034,7 @@ public class ScriptRuntimeContext
_gateway = gateway; _gateway = gateway;
_instanceName = instanceName; _instanceName = instanceName;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
@@ -969,7 +1069,8 @@ public class ScriptRuntimeContext
siteId: _siteId, siteId: _siteId,
instanceName: _instanceName, instanceName: _instanceName,
sourceScript: _sourceScript, sourceScript: _sourceScript,
logger: _logger); logger: _logger,
executionId: _executionId);
} }
/// <summary> /// <summary>
@@ -1000,7 +1101,12 @@ public class ScriptRuntimeContext
try try
{ {
await _gateway.CachedWriteAsync( await _gateway.CachedWriteAsync(
name, sql, parameters, _instanceName, cancellationToken, trackedId) name, sql, parameters, _instanceName, cancellationToken, trackedId,
// Audit Log #23 (ExecutionId Task 4): thread the script
// execution's ExecutionId + SourceScript so a buffered
// cached write's retry-loop audit rows carry them.
executionId: _executionId,
sourceScript: _sourceScript)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
@@ -1036,7 +1142,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound, Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit, Kind = AuditKind.CachedSubmit,
// CorrelationId = per-operation lifecycle id
// (TrackedOperationId); ExecutionId = per-execution id.
CorrelationId = trackedId.Value, CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -1098,6 +1207,12 @@ public class ScriptRuntimeContext
private readonly TimeSpan _askTimeout; private readonly TimeSpan _askTimeout;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </summary>
private readonly Guid _executionId;
/// <summary> /// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script /// <c>Notification</c>/<c>NotifySend</c> row produced when the script
@@ -1108,6 +1223,8 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other audit-threaded ctors.
internal NotifyHelper( internal NotifyHelper(
StoreAndForwardService? storeAndForward, StoreAndForwardService? storeAndForward,
ICanTell? siteCommunicationActor, ICanTell? siteCommunicationActor,
@@ -1116,6 +1233,7 @@ public class ScriptRuntimeContext
string? sourceScript, string? sourceScript,
TimeSpan askTimeout, TimeSpan askTimeout,
ILogger logger, ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null) IAuditWriter? auditWriter = null)
{ {
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
@@ -1125,6 +1243,7 @@ public class ScriptRuntimeContext
_sourceScript = sourceScript; _sourceScript = sourceScript;
_askTimeout = askTimeout; _askTimeout = askTimeout;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
} }
@@ -1135,6 +1254,9 @@ public class ScriptRuntimeContext
{ {
return new NotifyTarget( return new NotifyTarget(
listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger, listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger,
// Audit Log #23: the per-execution id stamped into the
// NotifySend row's ExecutionId column.
_executionId,
// Audit Log #23 (M4 Bundle C): forward the writer so Send() // Audit Log #23 (M4 Bundle C): forward the writer so Send()
// can emit one NotifySend(Submitted) row per accepted submission. // can emit one NotifySend(Submitted) row per accepted submission.
_auditWriter); _auditWriter);
@@ -1212,6 +1334,12 @@ public class ScriptRuntimeContext
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </summary>
private readonly Guid _executionId;
/// <summary> /// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after /// <c>Notification</c>/<c>NotifySend</c> row written immediately after
@@ -1227,6 +1355,7 @@ public class ScriptRuntimeContext
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger, ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null) IAuditWriter? auditWriter = null)
{ {
_listName = listName; _listName = listName;
@@ -1235,6 +1364,7 @@ public class ScriptRuntimeContext
_instanceName = instanceName; _instanceName = instanceName;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
} }
@@ -1277,7 +1407,12 @@ public class ScriptRuntimeContext
// notification, threaded down from the script-execution context for the // notification, threaded down from the script-execution context for the
// central audit trail. Null when no single script owns the context. // central audit trail. Null when no single script owns the context.
SourceScript: _sourceScript, SourceScript: _sourceScript,
SiteEnqueuedAt: DateTimeOffset.UtcNow); SiteEnqueuedAt: DateTimeOffset.UtcNow,
// OriginExecutionId (Audit Log #23): the SAME per-execution id stamped
// onto this run's NotifySend audit row. It rides inside the serialized
// payload through the S&F buffer to central, where the dispatcher echoes
// it onto the NotifyDeliver rows so all rows for one run share an id.
OriginExecutionId: _executionId);
var payloadJson = JsonSerializer.Serialize(payload); var payloadJson = JsonSerializer.Serialize(payload);
@@ -1351,11 +1486,17 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.Notification, Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend, Kind = AuditKind.NotifySend,
// CorrelationId is the NotificationId-derived per-operation
// lifecycle id; ExecutionId carries the per-execution id.
CorrelationId = correlationId, CorrelationId = correlationId,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
Actor = null, // Outbound channel: per the Audit Log Actor-column spec the
// actor is the calling script. Null when no single script
// owns the call (e.g. a shared script running inline).
Actor = _sourceScript,
Target = _listName, Target = _listName,
Status = AuditStatus.Submitted, Status = AuditStatus.Submitted,
HttpStatus = null, HttpStatus = null,
@@ -24,6 +24,13 @@ public class ParkedMessageHandlerActor : ReceiveActor
Receive<ParkedMessageQueryRequest>(HandleQuery); Receive<ParkedMessageQueryRequest>(HandleQuery);
Receive<ParkedMessageRetryRequest>(HandleRetry); Receive<ParkedMessageRetryRequest>(HandleRetry);
Receive<ParkedMessageDiscardRequest>(HandleDiscard); Receive<ParkedMessageDiscardRequest>(HandleDiscard);
// Task 5 (#22): central→site Retry/Discard relay for parked cached
// operations. The cached call's S&F buffer message id is the
// TrackedOperationId, so these reuse the same parked-message primitive
// as HandleRetry/HandleDiscard, keyed off the tracked id.
Receive<RetryParkedOperation>(HandleRetryParkedOperation);
Receive<DiscardParkedOperation>(HandleDiscardParkedOperation);
} }
private void HandleQuery(ParkedMessageQueryRequest msg) private void HandleQuery(ParkedMessageQueryRequest msg)
@@ -90,6 +97,46 @@ public class ParkedMessageHandlerActor : ReceiveActor
msg.CorrelationId, false, ex.GetBaseException().Message)); msg.CorrelationId, false, ex.GetBaseException().Message));
} }
/// <summary>
/// Task 5 (#22): executes a central-relayed Retry of a parked cached call.
/// The tracked id is the S&amp;F buffer message id, so this reuses
/// <see cref="StoreAndForwardService.RetryParkedMessageAsync"/> — which only
/// touches rows that are actually <c>Parked</c> (a non-parked or unknown
/// operation yields <c>false</c>, a safe no-op). Central never mutates the
/// central <c>SiteCalls</c> mirror; the reset row's corrected state flows
/// back via the normal cached-call telemetry path.
/// </summary>
private void HandleRetryParkedOperation(RetryParkedOperation msg)
{
var sender = Sender;
_service.RetryParkedMessageAsync(msg.TrackedOperationId.ToString())
.PipeTo(
sender,
success: applied => new ParkedOperationActionAck(
msg.CorrelationId, applied, ErrorMessage: null),
failure: ex => new ParkedOperationActionAck(
msg.CorrelationId, Applied: false, ex.GetBaseException().Message));
}
/// <summary>
/// Task 5 (#22): executes a central-relayed Discard of a parked cached call.
/// Mirrors <see cref="HandleRetryParkedOperation"/>; Discard removes the
/// parked S&amp;F buffer row (only when it is actually <c>Parked</c>).
/// </summary>
private void HandleDiscardParkedOperation(DiscardParkedOperation msg)
{
var sender = Sender;
_service.DiscardParkedMessageAsync(msg.TrackedOperationId.ToString())
.PipeTo(
sender,
success: applied => new ParkedOperationActionAck(
msg.CorrelationId, applied, ErrorMessage: null),
failure: ex => new ParkedOperationActionAck(
msg.CorrelationId, Applied: false, ex.GetBaseException().Message));
}
private static string ExtractMethodName(string payloadJson, Commons.Types.Enums.StoreAndForwardCategory category) private static string ExtractMethodName(string payloadJson, Commons.Types.Enums.StoreAndForwardCategory category)
{ {
if (string.IsNullOrEmpty(payloadJson)) if (string.IsNullOrEmpty(payloadJson))
@@ -55,4 +55,25 @@ public class StoreAndForwardMessage
/// WP-13: Messages are NOT cleared when instance is deleted. /// WP-13: Messages are NOT cleared when instance is deleted.
/// </summary> /// </summary>
public string? OriginInstanceName { get; set; } public string? OriginInstanceName { get; set; }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id, threaded from <c>ScriptRuntimeContext</c> through
/// the cached-call enqueue path. Carried so the store-and-forward retry loop
/// can stamp it onto the per-attempt / terminal cached-call audit rows
/// (<c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted, <c>CachedResolve</c>).
/// <c>null</c> for non-cached-call categories (notifications) and for rows
/// buffered before this field existed — back-compat with old persisted rows
/// (the column is added by an additive migration and read as null when absent).
/// </summary>
public Guid? ExecutionId { get; set; }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded alongside <see cref="ExecutionId"/> from the cached-call enqueue
/// path so the retry-loop audit rows carry the same <c>SourceScript</c>
/// provenance the script-side cached rows already carry. <c>null</c> when not
/// known (non-cached categories, pre-migration rows).
/// </summary>
public string? SourceScript { get; set; }
} }
@@ -175,6 +175,18 @@ public class StoreAndForwardService
/// it is the buffered row's <see cref="StoreAndForwardMessage.Id"/>, it is carried /// it is the buffered row's <see cref="StoreAndForwardMessage.Id"/>, it is carried
/// inside the payload, and it is the id the forwarder submits to central. /// inside the payload, and it is the id the forwarder submits to central.
/// </param> /// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. Threaded onto the buffered row so the retry-loop
/// cached-call audit rows carry it. <c>null</c> for callers (notifications,
/// pre-Task-4 callers) that do not supply one.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered row alongside <paramref name="executionId"/>
/// so the retry-loop audit rows carry the same provenance the script-side
/// cached rows do. <c>null</c> when not known.
/// </param>
public async Task<StoreAndForwardResult> EnqueueAsync( public async Task<StoreAndForwardResult> EnqueueAsync(
StoreAndForwardCategory category, StoreAndForwardCategory category,
string target, string target,
@@ -183,7 +195,9 @@ public class StoreAndForwardService
int? maxRetries = null, int? maxRetries = null,
TimeSpan? retryInterval = null, TimeSpan? retryInterval = null,
bool attemptImmediateDelivery = true, bool attemptImmediateDelivery = true,
string? messageId = null) string? messageId = null,
Guid? executionId = null,
string? sourceScript = null)
{ {
var message = new StoreAndForwardMessage var message = new StoreAndForwardMessage
{ {
@@ -196,7 +210,9 @@ public class StoreAndForwardService
RetryIntervalMs = (long)(retryInterval ?? _options.DefaultRetryInterval).TotalMilliseconds, RetryIntervalMs = (long)(retryInterval ?? _options.DefaultRetryInterval).TotalMilliseconds,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending, Status = StoreAndForwardMessageStatus.Pending,
OriginInstanceName = originInstanceName OriginInstanceName = originInstanceName,
ExecutionId = executionId,
SourceScript = sourceScript
}; };
// Attempt immediate delivery — unless the caller has already made a // Attempt immediate delivery — unless the caller has already made a
@@ -492,7 +508,14 @@ public class StoreAndForwardService
CreatedAtUtc: message.CreatedAt.UtcDateTime, CreatedAtUtc: message.CreatedAt.UtcDateTime,
OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
DurationMs: durationMs, DurationMs: durationMs,
SourceInstanceId: message.OriginInstanceName); SourceInstanceId: message.OriginInstanceName,
// Audit Log #23 (ExecutionId Task 4): the buffered message
// carries the originating script execution's ExecutionId +
// SourceScript; surface them on the context so the bridge can
// stamp the retry-loop cached audit rows. Null on rows buffered
// before Task 4 (back-compat).
ExecutionId: message.ExecutionId,
SourceScript: message.SourceScript);
} }
catch (Exception buildEx) catch (Exception buildEx)
{ {
@@ -65,9 +65,45 @@ public class StoreAndForwardStorage
"; ";
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
// Audit Log #23 (ExecutionId Task 4): additively add the execution_id /
// source_script columns. CREATE TABLE IF NOT EXISTS above does NOT add
// columns to a table that already exists from before these fields, so a
// databases created by an older build needs the columns ALTER-ed in.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. Both columns
// are nullable with no default, so any row buffered before this
// migration reads back ExecutionId/SourceScript = null (back-compat).
await AddColumnIfMissingAsync(connection, "execution_id", "TEXT");
await AddColumnIfMissingAsync(connection, "source_script", "TEXT");
_logger.LogInformation("Store-and-forward SQLite storage initialized"); _logger.LogInformation("Store-and-forward SQLite storage initialized");
} }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): adds a column to <c>sf_messages</c>
/// only when it is not already present. SQLite lacks <c>ADD COLUMN IF NOT
/// EXISTS</c>, so the schema is probed via <c>PRAGMA table_info</c> first.
/// Idempotent — safe to run on every <see cref="InitializeAsync"/>.
/// </summary>
private static async Task AddColumnIfMissingAsync(
SqliteConnection connection, string columnName, string columnType)
{
await using var probe = connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('sf_messages') WHERE name = @name";
probe.Parameters.AddWithValue("@name", columnName);
var exists = Convert.ToInt32(await probe.ExecuteScalarAsync()) > 0;
if (exists)
{
return;
}
await using var alter = connection.CreateCommand();
// Column name + type are caller-controlled constants, never user input —
// safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE sf_messages ADD COLUMN {columnName} {columnType}";
await alter.ExecuteNonQueryAsync();
}
/// <summary> /// <summary>
/// Ensures the directory for a file-backed SQLite database exists. SQLite creates /// Ensures the directory for a file-backed SQLite database exists. SQLite creates
/// the database file on demand but not its parent directory, so a configured path /// the database file on demand but not its parent directory, so a configured path
@@ -105,9 +141,11 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand(); await using var cmd = connection.CreateCommand();
cmd.CommandText = @" cmd.CommandText = @"
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries, INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance) retry_interval_ms, created_at, last_attempt_at, status, last_error,
origin_instance, execution_id, source_script)
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries, VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError, @origin)"; @retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
@origin, @executionId, @sourceScript)";
cmd.Parameters.AddWithValue("@id", message.Id); cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category); cmd.Parameters.AddWithValue("@category", (int)message.Category);
@@ -122,6 +160,12 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@status", (int)message.Status); cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value); cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value); cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value);
// Audit Log #23 (ExecutionId Task 4): the execution id is stored as its
// canonical string form ("D") so it round-trips cleanly through the
// TEXT column; null when not a cached call / not threaded.
cmd.Parameters.AddWithValue("@executionId",
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
} }
@@ -137,7 +181,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand(); await using var cmd = connection.CreateCommand();
cmd.CommandText = @" cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries, SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages FROM sf_messages
WHERE status = @pending WHERE status = @pending
AND (last_attempt_at IS NULL AND (last_attempt_at IS NULL
@@ -268,7 +313,8 @@ public class StoreAndForwardStorage
var categoryFilter = category.HasValue ? " AND category = @category" : ""; var categoryFilter = category.HasValue ? " AND category = @category" : "";
pageCmd.CommandText = $@" pageCmd.CommandText = $@"
SELECT id, category, target, payload_json, retry_count, max_retries, SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages FROM sf_messages
WHERE status = @parked{categoryFilter} WHERE status = @parked{categoryFilter}
ORDER BY created_at ASC ORDER BY created_at ASC
@@ -389,7 +435,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand(); await using var cmd = connection.CreateCommand();
cmd.CommandText = @" cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries, SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages FROM sf_messages
WHERE id = @id"; WHERE id = @id";
cmd.Parameters.AddWithValue("@id", messageId); cmd.Parameters.AddWithValue("@id", messageId);
@@ -446,9 +493,35 @@ public class StoreAndForwardStorage
LastAttemptAt = reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)), LastAttemptAt = reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)),
Status = (StoreAndForwardMessageStatus)reader.GetInt32(9), Status = (StoreAndForwardMessageStatus)reader.GetInt32(9),
LastError = reader.IsDBNull(10) ? null : reader.GetString(10), LastError = reader.IsDBNull(10) ? null : reader.GetString(10),
OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11) OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11),
// Audit Log #23 (ExecutionId Task 4): rows persisted before the
// additive migration have no execution_id / source_script value;
// IsDBNull guards keep those reading back as null (back-compat).
// Guid.TryParse (not Parse) guards the retry sweep: a corrupt
// non-null execution_id is treated as "no execution id" rather
// than throwing FormatException and aborting the whole sweep.
ExecutionId = ParseExecutionId(reader, 12),
SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13)
}); });
} }
return results; return results;
} }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): defensively reads the
/// <c>execution_id</c> column. A <c>null</c> value (legacy pre-migration
/// rows) and a malformed non-null value both yield <c>null</c> — a corrupt
/// id must not throw and abort the retry sweep, which reads many rows.
/// </summary>
private static Guid? ParseExecutionId(System.Data.Common.DbDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return null;
}
return Guid.TryParse(reader.GetString(ordinal), out var executionId)
? executionId
: null;
}
} }
@@ -356,6 +356,12 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
_inner.QueryAsync(filter, paging, ct); _inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct); _inner.PurgeTerminalAsync(olderThanUtc, ct);
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
} }
/// <summary> /// <summary>
@@ -387,5 +393,11 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
_inner.QueryAsync(filter, paging, ct); _inner.QueryAsync(filter, paging, ct);
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
_inner.PurgeTerminalAsync(olderThanUtc, ct); _inner.PurgeTerminalAsync(olderThanUtc, ct);
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
} }
} }

Some files were not shown because too many files have changed in this diff Show More