37 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
117 changed files with 9412 additions and 573 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.
- 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`).
- `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.
- 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.
@@ -11,12 +11,17 @@
>
> **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`
> returns HTTP 501), per-channel retention overrides. **Deferred follow-ups noted during
> implementation:** the real site→central gRPC push client (M6 wired the pull RPC + a mockable
> push seam; `NoOpSiteStreamAuditClient` remains the production binding); consolidation of the
> 4 DTO mapper copies; Site Calls UI page + its Audit drill-in; multi-value filter dimensions
> (`AuditLogQueryFilter` is single-value per dimension, so UI chips / CLI flags collapse to the
> first value); audit-results-grid drag resize/reorder UX.
> returns HTTP 501), per-channel retention overrides. **Follow-ups noted during
> implementation — now complete:** the five follow-ups deferred above (the real
> site→central push client; consolidation of the 4 DTO mapper copies; the Site Calls UI
> page + its Audit drill-in; multi-value filter dimensions; audit-results-grid drag
> resize/reorder UX) were all implemented on the `feature/audit-log-followups` branch
> 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.**
@@ -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"
}
@@ -1,17 +1,17 @@
{
"planPath": "docs/plans/2026-05-21-audit-log-followups.md",
"tasks": [
{"id": 33, "subject": "Task 0: Prep — feature branch", "status": "pending"},
{"id": 34, "subject": "Task 1: Audit push — central ingest routing over ClusterClient", "status": "pending", "blockedBy": [33]},
{"id": 35, "subject": "Task 2: Audit push — real site client, Host wiring, integration test", "status": "pending", "blockedBy": [34]},
{"id": 36, "subject": "Task 3: Consolidate the duplicated audit DTO mappers", "status": "pending", "blockedBy": [33]},
{"id": 37, "subject": "Task 4: Site Call Audit — query / KPI / detail backend", "status": "pending", "blockedBy": [33]},
{"id": 38, "subject": "Task 5: Site Call Audit — Retry/Discard relay to owning site", "status": "pending", "blockedBy": [37]},
{"id": 39, "subject": "Task 6: Site Calls UI page + nav + Audit drill-in", "status": "pending", "blockedBy": [37, 38]},
{"id": 40, "subject": "Task 7: Site Call KPI tiles + Health dashboard integration", "status": "pending", "blockedBy": [37]},
{"id": 41, "subject": "Task 8: Multi-value AuditLogQueryFilter — contract + repository", "status": "pending", "blockedBy": [33]},
{"id": 42, "subject": "Task 9: Multi-value filters — ManagementService, CLI, Central UI", "status": "pending", "blockedBy": [41]},
{"id": 43, "subject": "Task 10: Audit results grid — column resize + reorder UX", "status": "pending", "blockedBy": [33]}
{"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-21T07:30:00Z"
"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`. |
| `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. |
| `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. |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `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_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_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).
@@ -126,6 +128,27 @@ Inbound API is intentionally collapsed to a single `InboundRequest` (or
`InboundAuthFailure` for auth rejections) row per request rather than a
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)
A SQLite database file on each site node, alongside the Store-and-Forward
@@ -114,12 +114,53 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
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>
@@ -221,12 +262,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
$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 pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
foreach (var pending in batch)
{
@@ -274,6 +318,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
try
{
@@ -331,7 +376,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog
WHERE ForwardState = $pending
ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -379,7 +425,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog
WHERE ForwardState = $forwarded
ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -465,7 +512,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog
WHERE ForwardState IN ($pending, $forwarded)
AND OccurredAtUtc >= $since
@@ -642,6 +690,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
PayloadTruncated = reader.GetInt32(17) != 0,
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
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,
Kind = kind,
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,
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,
Status = status,
HttpStatus = httpStatus,
@@ -34,15 +34,17 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
/// returns normally.
/// </para>
/// <para>
/// <b>Wire push deferred to M6.</b> M3 keeps this forwarder synchronous
/// against the local stores: there is no site→central gRPC channel yet, so
/// the <see cref="ISiteStreamAuditClient.IngestCachedTelemetryAsync"/> RPC
/// is registered on the interface (Bundle E1) but the production binding
/// remains <c>NoOpSiteStreamAuditClient</c>. Once M6 wires a real client the
/// drain pattern from <c>SiteAuditTelemetryActor</c> can be reused — the
/// <c>AuditEvent</c> rows already live in SQLite tagged
/// <see cref="AuditForwardState.Pending"/>, so a single drain loop sweeps
/// both M2 and M3 emissions.
/// <b>Local-write only — the wire push is the drain actor's job.</b> This
/// forwarder is deliberately synchronous against the two site-local SQLite
/// stores and never pushes to central itself. The site→central transport is
/// now live: <c>ClusterClientSiteAuditClient</c> is the production binding of
/// <see cref="ISiteStreamAuditClient"/> on site roles (with
/// <c>NoOpSiteStreamAuditClient</c> retained only for central/test composition
/// roots). The push happens out-of-band: <see cref="SiteAuditTelemetryActor"/>
/// sweeps the <c>AuditEvent</c> rows this forwarder wrote — they live in SQLite
/// 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>
/// </remarks>
public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
+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 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");
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(
"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)" };
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" };
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 actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
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 pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
pageSizeOption.DefaultValueFactory = _ => 100;
@@ -54,6 +75,7 @@ public static class AuditCommands
cmd.Add(targetOption);
cmd.Add(actorOption);
cmd.Add(correlationIdOption);
cmd.Add(executionIdOption);
cmd.Add(errorsOnlyOption);
cmd.Add(pageSizeOption);
cmd.Add(allOption);
@@ -74,13 +96,14 @@ public static class AuditCommands
{
Since = result.GetValue(sinceOption),
Until = result.GetValue(untilOption),
Channel = result.GetValue(channelOption),
Kind = result.GetValue(kindOption),
Status = result.GetValue(statusOption),
Site = result.GetValue(siteOption),
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption),
CorrelationId = result.GetValue(correlationIdOption),
ExecutionId = result.GetValue(executionIdOption),
ErrorsOnly = result.GetValue(errorsOnlyOption),
PageSize = result.GetValue(pageSizeOption),
};
@@ -108,10 +131,36 @@ public static class AuditCommands
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel" };
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" };
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
// --channel/--kind/--status/--site are multi-valued — same shape as the
// `query` subcommand: 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");
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 actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
@@ -142,10 +191,10 @@ public static class AuditCommands
Until = result.GetValue(untilOption)!,
Format = result.GetValue(formatExportOption)!,
Output = result.GetValue(outputOption)!,
Channel = result.GetValue(channelOption),
Kind = result.GetValue(kindOption),
Status = result.GetValue(statusOption),
Site = result.GetValue(siteOption),
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption),
};
@@ -6,6 +6,10 @@ namespace ScadaLink.CLI.Commands;
/// <summary>
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
/// 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>
public sealed class AuditExportArgs
{
@@ -13,10 +17,10 @@ public sealed class AuditExportArgs
public string Until { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public string Output { get; set; } = string.Empty;
public string? Channel { get; set; }
public string? Kind { get; set; }
public string? Status { get; set; }
public string? Site { get; set; }
public string[] Channel { get; set; } = Array.Empty<string>();
public string[] Kind { get; set; } = Array.Empty<string>();
public string[] Status { get; set; } = Array.Empty<string>();
public string[] Site { get; set; } = Array.Empty<string>();
public string? Target { get; set; }
public string? Actor { get; set; }
}
@@ -31,7 +35,11 @@ public static class AuditExportHelpers
/// <summary>
/// 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
/// <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>
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
{
@@ -43,13 +51,21 @@ public static class AuditExportHelpers
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("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
Add("format", args.Format);
Add("channel", args.Channel);
Add("kind", args.Kind);
Add("status", args.Status);
Add("sourceSiteId", args.Site);
AddEach("channel", args.Channel);
AddEach("kind", args.Kind);
AddEach("status", args.Status);
AddEach("sourceSiteId", args.Site);
Add("target", args.Target);
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
/// <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).
/// <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>
public sealed class AuditQueryArgs
{
public string? Since { get; set; }
public string? Until { get; set; }
public string? Channel { get; set; }
public string? Kind { get; set; }
public string? Status { get; set; }
public string? Site { get; set; }
public string[] Channel { get; set; } = Array.Empty<string>();
public string[] Kind { get; set; } = Array.Empty<string>();
public string[] Status { get; set; } = Array.Empty<string>();
public string[] Site { get; set; } = Array.Empty<string>();
public string? Target { get; set; }
public string? Actor { get; set; }
public string? CorrelationId { get; set; }
public string? ExecutionId { get; set; }
public bool ErrorsOnly { get; set; }
public int PageSize { get; set; } = 100;
}
@@ -73,8 +77,11 @@ public static class AuditQueryHelpers
/// <summary>
/// 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>
/// maps to <c>status=Failed</c> (the server takes a single status value).
/// args plus an optional keyset cursor. Unset filters are omitted. 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. <c>--errors-only</c>
/// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
/// </summary>
public static string BuildQueryString(
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
@@ -87,23 +94,39 @@ public static class AuditQueryHelpers
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))
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(args.Until))
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
Add("channel", args.Channel);
Add("kind", args.Kind);
AddEach("channel", args.Channel);
AddEach("kind", args.Kind);
// --errors-only is a convenience shorthand for the single-value Failed status
// filter. The server's status filter accepts one value, so --errors-only and an
// explicit --status are mutually exclusive in effect; --errors-only wins.
Add("status", args.ErrorsOnly ? "Failed" : args.Status);
// --errors-only is a convenience shorthand for the Failed status filter. The
// server's status filter is multi-value, but --errors-only stays a single-status
// override: it pins status=Failed and supersedes any explicit --status values.
if (args.ErrorsOnly)
{
Add("status", "Failed");
}
else
{
AddEach("status", args.Status);
}
Add("sourceSiteId", args.Site);
AddEach("sourceSiteId", args.Site);
Add("target", args.Target);
Add("actor", args.Actor);
Add("correlationId", args.CorrelationId);
Add("executionId", args.ExecutionId);
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
if (afterOccurredAtUtc.HasValue)
+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 |
| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`) |
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`) |
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`) |
| `--site` | no | — | Filter by source site ID |
| `--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`); repeatable — multiple values are OR-combined |
| `--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; repeatable — multiple values are OR-combined |
| `--target` | no | — | Filter by target (external system, DB connection, notification list) |
| `--actor` | no | — | Filter by actor |
| `--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 |
| `--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
`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`,
`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With
@@ -74,34 +74,27 @@ public static class AuditExportEndpoints
}
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>.
/// Unknown enum names / un-parseable Guids / dates are silently dropped
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>).
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c> dimensions are
/// 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>
/// <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)
{
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
var sites = AuditQueryParamParsers.ParseStringList(query["site"]);
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? actor = TrimToNullable(query, "actor");
@@ -112,17 +105,25 @@ public static class AuditExportEndpoints
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? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: site,
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId,
ExecutionId: executionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}
@@ -55,6 +55,9 @@
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<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>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
@@ -151,6 +154,14 @@
Show all events for this operation
</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"
data-test="drawer-close-footer"
@onclick="HandleClose">
@@ -47,9 +47,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <para>
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
/// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. The parent page does not
/// auto-apply that filter today — it is a deep link the page can use
/// when Bundle D wires up query-string deserialization.
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// 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>
/// </summary>
public partial class AuditDrilldownDrawer
@@ -276,6 +277,20 @@ public partial class AuditDrilldownDrawer
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>
/// Build a cURL command from an audit event. The URL comes from
/// <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-body py-2">
@* Channel chip multi-select. *@
<div class="mb-2" data-test="filter-channel">
<label class="form-label small mb-1">Channel</label>
<div>
@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>
@* All filters sit in one wrapped row. Kind / Status / Site use compact
MultiSelectDropdown controls; Channel is a single-select because the
Kind options narrow to the chosen channel — so the bar stays a row or
two tall instead of four stacked blocks of chip buttons. *@
<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">
<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"
@@ -137,6 +117,16 @@
placeholder="contains…" @bind="_model.ActorSearch" />
</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="form-check mb-1">
<input class="form-check-input" type="checkbox" id="audit-errors-only"
@@ -7,19 +7,32 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements
/// plus the Errors-only toggle, and publishes a collapsed
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select
/// single-value collapse contract.
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// — Channel as a single-select (one channel at a time, so the Kind options
/// narrow to it cleanly); Kind / Status / Site as compact
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// 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>
public partial class AuditFilterBar
{
private readonly AuditQueryModel _model = 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>
/// 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="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
/// </summary>
@@ -51,10 +64,9 @@ public partial class AuditFilterBar
_model.InstanceSearch = InitialInstanceSearch.Trim();
}
// Populate the Site chips at component init. Failure is non-fatal — the chip
// section just shows "No sites available." Sites are listed by Name to match
// operator expectations from the Notification Report.
// Populate the Site dropdown at component init. Failure is non-fatal — the
// dropdown just shows "No sites available." Sites are listed by Name to
// match operator expectations from the Notification Report.
try
{
var sites = await SiteRepository.GetAllSitesAsync();
@@ -62,48 +74,52 @@ public partial class AuditFilterBar
}
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).
_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
// Kind both picked" coherent — without this, removing a channel could leave
// stale Kind chips selected that no longer match any visible chip.
OnChannelsChanged();
}
}
/// <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();
_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))
{
_model.Kinds.Remove(kind);
}
}
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);
}
var site = _sites.FirstOrDefault(s =>
string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
return site?.Name ?? siteIdentifier;
}
private void ClearFilters()
@@ -119,6 +135,7 @@ public partial class AuditFilterBar
_model.ScriptSearch = string.Empty;
_model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty;
_model.ExecutionId = string.Empty;
_model.ErrorsOnly = false;
}
@@ -129,11 +146,6 @@ public partial class AuditFilterBar
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
{
AuditTimeRangePreset.Last5Minutes => "now 5 min → now",
@@ -15,20 +15,20 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// </para>
///
/// <para>
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is single-value
/// per dimension today; the chip multi-selects therefore collapse to the FIRST
/// selected chip when the model is published via <see cref="ToFilter"/>. That is a
/// deliberate Bundle B scope decision — the chip UI is preserved so a follow-up can
/// either repeat the query per chip or widen the filter contract without rewriting
/// the form. Instance and Script free-text are also UI-only today: the underlying
/// filter has no matching columns, so they are dropped during collapse.
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is multi-value
/// per dimension: the chip multi-selects map straight through to the
/// <c>Channels</c> / <c>Kinds</c> / <c>Statuses</c> / <c>SourceSiteIds</c> filter
/// lists when the model is published via <see cref="ToFilter"/> — an empty set means
/// "do not constrain". Instance and Script free-text remain UI-only: the underlying
/// filter has no matching columns, so they are dropped when the model is published.
/// </para>
///
/// <para>
/// 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
/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle
/// is a no-op — the explicit Status filter wins.
/// are selected, <see cref="ToFilter"/> targets the full error-status set
/// {<see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
/// <see cref="AuditStatus.Discarded"/>}. When Status chips ARE selected the toggle
/// is a no-op — the explicit Status chips win.
/// </para>
/// </summary>
public sealed class AuditQueryModel
@@ -47,6 +47,14 @@ public sealed class AuditQueryModel
public string TargetSearch { 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; }
/// <summary>
@@ -104,41 +112,51 @@ public sealed class AuditQueryModel
}
/// <summary>
/// Collapses this UI model to the repository's single-value filter.
/// See class doc for the multi-select → single-value contract.
/// Publishes this UI model as the repository's multi-value filter: each chip
/// 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>
public AuditLogQueryFilter ToFilter(DateTime utcNow)
{
var status = ResolveStatus();
var statuses = ResolveStatuses();
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(
Channel: Channels.Count > 0 ? Channels.First() : null,
Kind: Kinds.Count > 0 ? Kinds.First() : null,
Status: status,
SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null,
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
Statuses: statuses,
SourceSiteIds: SiteIdentifiers.Count > 0 ? SiteIdentifiers.ToArray() : null,
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
ExecutionId: executionId,
FromUtc: fromUtc,
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)
{
// Explicit chips win — Errors-only is a no-op.
return Statuses.First();
return Statuses.ToArray();
}
if (ErrorsOnly)
{
// Single-value filter contract: Failed is the lead non-success status.
// When the filter widens to multi-value the full {Failed, Parked, Discarded}
// set will flow through.
return AuditStatus.Failed;
// Multi-value filter: Errors-only targets the full non-success set.
return ErrorStatuses;
}
return null;
@@ -12,12 +12,26 @@
}
<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">
<tr>
@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>
</thead>
@@ -48,7 +62,7 @@
@onclick="() => HandleRowClick(row)">
@foreach (var col in OrderedColumns())
{
<td>
<td class="audit-grid-td" style="@ColumnWidthStyle(col.Key)">
@RenderCell(col.Key, row)
</td>
}
@@ -69,6 +83,15 @@
</div>
@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 =>
{
switch (key)
@@ -97,6 +120,18 @@
case "Actor":
<span class="small">@(row.Actor ?? "—")</span>
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":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break;
@@ -1,4 +1,6 @@
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
@@ -7,19 +9,23 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
/// Renders the 10 columns named in Component-AuditLog.md §10:
/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs,
/// HttpStatus, ErrorMessage. Talks to <see cref="Services.IAuditLogQueryService"/>
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// 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
/// source without standing up EF Core.
///
/// <para>
/// <b>Column model.</b> Each column has a stable string key; the visible order
/// is the <see cref="ColumnOrder"/> parameter. M7 scope: the column-model
/// framework is in place but resize / drag-reorder UX is intentionally NOT
/// implemented — the full spec calls for persisted-per-user reordering and
/// resizing, which M7.x can ship without rewriting the column model. Resizing
/// today is CSS-based via Bootstrap's <c>.table-responsive</c> wrapper.
/// <b>Column model.</b> Each column has a stable string key. The default
/// visible order is the <see cref="ColumnOrder"/> parameter (or the spec
/// order from Component-AuditLog.md §10 when the parameter is null). On top of
/// that default the grid layers a per-browser override: drag-to-reorder and
/// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column
/// 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>
@@ -32,11 +38,28 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
/// end" signal for keyset paging without a count query.
/// </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>
public partial class AuditResultsGrid
public partial class AuditResultsGrid : IAsyncDisposable
{
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 int _pageNumber = 1;
private bool _loading;
@@ -44,6 +67,18 @@ public partial class AuditResultsGrid
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>
/// 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
@@ -75,6 +110,9 @@ public partial class AuditResultsGrid
/// <c>data-test</c> + the column-order parameter); the label is the user-facing
/// header text. Mirrors Component-AuditLog.md §10.
/// </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[]
{
("OccurredAtUtc", "OccurredAtUtc"),
@@ -84,30 +122,64 @@ public partial class AuditResultsGrid
("Status", "Status"),
("Target", "Target"),
("Actor", "Actor"),
("ExecutionId", "ExecutionId"),
("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"),
};
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;
}
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
var ordered = new List<(string Key, string Label)>(ColumnOrder.Count);
foreach (var key in ColumnOrder)
var ordered = new List<(string Key, string Label)>(AllColumns.Count);
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);
}
}
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()
{
// 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
{
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");
}
}
@@ -19,10 +19,11 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <para>
/// 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>,
/// <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
/// 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
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
@@ -60,6 +61,16 @@ public partial class AuditLogPage
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;
if (query.TryGetValue("target", out var targetValues))
{
@@ -80,33 +91,27 @@ public partial class AuditLogPage
}
}
string? site = null;
if (query.TryGetValue("site", out var siteValues))
{
var v = siteValues.ToString();
if (!string.IsNullOrWhiteSpace(v))
{
site = v.Trim();
}
}
// site/channel/kind/status accept repeated params for symmetry with the
// multi-value export URL — a single ?site=/?channel=/?kind=/?status=
// drill-in still works (one-element list). Unknown enum names are silently
// dropped. The lax-parse contract is shared with the two export endpoints
// via AuditQueryParamParsers so all three surfaces stay in lockstep.
IReadOnlyList<string>? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site"));
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
IReadOnlyList<AuditChannel>? channels =
AuditQueryParamParsers.ParseEnumList<AuditChannel>(Raw(query, "channel"));
// ?kind= is honored for symmetry with BuildExportUrl, which emits a kind=
// 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
// with ?status=Failed (and operators may craft URLs with Parked/Discarded).
// Unknown values are silently dropped — the page still renders without
// the constraint.
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
IReadOnlyList<AuditStatus>? statuses =
AuditQueryParamParsers.ParseEnumList<AuditStatus>(Raw(query, "status"));
// Instance is UI-only — the filter contract has no matching column, so we
// 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
// because the filter contract has no instance column — the user still needs
// 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;
}
_currentFilter = new AuditLogQueryFilter(
Channel: channel,
Status: status,
SourceSiteId: site,
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sites,
Target: target,
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)
{
// Always reassign — the grid keys reloads on reference change, so even a
@@ -180,22 +199,42 @@ public partial class AuditLogPage
return basePath;
}
var parts = new List<KeyValuePair<string, string?>>(9);
if (filter.Channel is { } ch)
// No capacity hint: the dimensions are multi-value, so the part count is
// 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))
{
@@ -209,6 +248,10 @@ public partial class AuditLogPage
{
parts.Add(new("correlationId", corr.ToString()));
}
if (filter.ExecutionId is { } exec)
{
parts.Add(new("executionId", exec.ToString()));
}
if (filter.FromUtc is { } from)
{
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
@@ -8,6 +8,7 @@
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.HealthMonitoring
@using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Commons.Messages.Audit
@using ScadaLink.Communication
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
@@ -60,6 +61,12 @@
<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
(volume / error rate / backlog). Refreshed alongside the site states. *@
<AuditKpiTiles Snapshot="@_auditKpi"
@@ -364,6 +371,13 @@
private bool _auditKpiAvailable;
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)
{
var report = state.LatestReport;
@@ -401,6 +415,7 @@
{
_siteStates = HealthAggregator.GetAllSiteStates();
await LoadOutboxKpis();
await LoadSiteCallKpis();
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
// KPI query failed — matching how the page renders other unavailable data.
private string OutboxTileValue(int value) =>
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
@@ -26,11 +28,32 @@ namespace ScadaLink.CentralUI.Components.Pages.SiteCalls;
/// 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();
@@ -77,9 +100,51 @@ public partial class SiteCallsReport
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()
{
@@ -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>
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>
public string? SourceSiteId { get; init; }
@@ -27,6 +27,15 @@ public class Notification
public string SourceSiteId { get; set; }
public string? SourceInstanceId { 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; }
/// <summary>Central ingest time.</summary>
@@ -57,6 +57,20 @@ public interface ICachedCallLifecycleObserver
/// <param name="OccurredAtUtc">When this attempt completed.</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="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(
TrackedOperationId TrackedOperationId,
string Channel,
@@ -69,7 +83,9 @@ public sealed record CachedCallAttemptContext(
DateTime CreatedAtUtc,
DateTime OccurredAtUtc,
int? DurationMs,
string? SourceInstanceId);
string? SourceInstanceId,
Guid? ExecutionId = null,
string? SourceScript = null);
/// <summary>
/// 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
/// M3 telemetry is correlated (pre-M3 caller behaviour).
/// </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(
string connectionName,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
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
/// on).
/// </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(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null);
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null);
}
/// <summary>
@@ -4,6 +4,13 @@ namespace ScadaLink.Commons.Messages.Notification;
/// Site -> Central: submit a notification for central delivery.
/// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received.
/// </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(
string NotificationId,
string ListName,
@@ -12,7 +19,8 @@ public record NotificationSubmit(
string SourceSiteId,
string? SourceInstanceId,
string? SourceScript,
DateTimeOffset SiteEnqueuedAt);
DateTimeOffset SiteEnqueuedAt,
Guid? OriginExecutionId = null);
/// <summary>
/// Central -> Site: ack sent after the notification row is persisted.
@@ -4,18 +4,25 @@ namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// 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
/// 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 fields are AND-combined.
/// Any field left <c>null</c> means "do not constrain on that column". The
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
/// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
/// 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>
public sealed record AuditLogQueryFilter(
AuditChannel? Channel = null,
AuditKind? Kind = null,
AuditStatus? Status = null,
string? SourceSiteId = null,
IReadOnlyList<AuditChannel>? Channels = null,
IReadOnlyList<AuditKind>? Kinds = null,
IReadOnlyList<AuditStatus>? Statuses = null,
IReadOnlyList<string>? SourceSiteIds = null,
string? Target = null,
string? Actor = null,
Guid? CorrelationId = null,
Guid? ExecutionId = null,
DateTime? FromUtc = 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;
}
}
@@ -355,6 +355,14 @@ public class CommunicationService
/// 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)
@@ -47,6 +47,7 @@ public static class AuditEventDtoMapper
Channel = evt.Channel.ToString(),
Kind = evt.Kind.ToString(),
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
SourceSiteId = evt.SourceSiteId ?? string.Empty,
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
SourceScript = evt.SourceScript ?? string.Empty,
@@ -92,6 +93,7 @@ public static class AuditEventDtoMapper
Channel = Enum.Parse<AuditChannel>(dto.Channel),
Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript),
@@ -91,6 +91,7 @@ message AuditEventDto {
string response_summary = 17;
bool payload_truncated = 18;
string extra = 19;
string execution_id = 20; // empty string represents null
}
message AuditEventBatch { repeated AuditEventDto events = 1; }
@@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc {
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAki9QMKDUF1ZGl0RXZlbnRE",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiiwQKDUF1ZGl0RXZlbnRE",
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
@@ -52,43 +52,43 @@ namespace ScadaLink.Communication.Grpc {
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk",
"aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk",
"aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz",
"GAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRf",
"b3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0",
"GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRIT",
"CgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0",
"dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUS",
"MgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt",
"ZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3Rv",
"YnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29n",
"bGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr",
"ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl",
"bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD",
"YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH",
"cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj",
"a2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRjGAEg",
"ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXpl",
"GAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50cxgB",
"IAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2YWls",
"YWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQ",
"ABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8K",
"C1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RB",
"VEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJB",
"TEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxB",
"Uk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJN",
"X0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxB",
"Uk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNlElUK",
"EVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVh",
"bVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcKEUlu",
"Z2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0Y2ga",
"FS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxlbWV0",
"cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0ZXN0",
"cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0cmVh",
"bS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsQXVk",
"aXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRpb24u",
"R3JwY2IGcHJvdG8z"));
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
"Y3V0aW9uX2lkGBQgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZldmVudHMY",
"ASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5nZXN0QWNr",
"EhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSL0AgoWU2l0ZUNhbGxPcGVy",
"YXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEoCRIPCgdj",
"aGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vfc2l0ZRgE",
"IAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiABKAUSEgoK",
"bGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIbLmdvb2ds",
"ZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRjGAkgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVkX2F0X3V0",
"YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoPdGVybWlu",
"YWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKA",
"AQoVQ2FjaGVkVGVsZW1ldHJ5UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgL",
"Mhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIg",
"ASgLMiIuc2l0ZXN0cmVhbS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENh",
"Y2hlZFRlbGVtZXRyeUJhdGNoEjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3Ry",
"ZWFtLkNhY2hlZFRlbGVtZXRyeVBhY2tldCJbChZQdWxsQXVkaXRFdmVudHNS",
"ZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U",
"aW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJcChdQdWxsQXVkaXRFdmVu",
"dHNSZXNwb25zZRIpCgZldmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0",
"RXZlbnREdG8SFgoObW9yZV9hdmFpbGFibGUYAiABKAgqXAoHUXVhbGl0eRIX",
"ChNRVUFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoR",
"UVVBTElUWV9VTkNFUlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJt",
"U3RhdGVFbnVtEhsKF0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxB",
"Uk1fU1RBVEVfTk9STUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIqhQEK",
"DkFsYXJtTGV2ZWxFbnVtEhQKEEFMQVJNX0xFVkVMX05PTkUQABITCg9BTEFS",
"TV9MRVZFTF9MT1cQARIXChNBTEFSTV9MRVZFTF9MT1dfTE9XEAISFAoQQUxB",
"Uk1fTEVWRUxfSElHSBADEhkKFUFMQVJNX0xFVkVMX0hJR0hfSElHSBAEMuEC",
"ChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0YW5jZRIhLnNp",
"dGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5T",
"aXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVz",
"dHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sS",
"UAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRU",
"ZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloKD1B1bGxB",
"dWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVxdWVz",
"dBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2VCH6oCHFNj",
"YWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
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[] {
@@ -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.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.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.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),
@@ -1591,6 +1591,7 @@ namespace ScadaLink.Communication.Grpc {
responseSummary_ = other.responseSummary_;
payloadTruncated_ = other.payloadTruncated_;
extra_ = other.extra_;
executionId_ = other.executionId_;
_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.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -1872,6 +1888,7 @@ namespace ScadaLink.Communication.Grpc {
if (ResponseSummary != other.ResponseSummary) return false;
if (PayloadTruncated != other.PayloadTruncated) return false;
if (Extra != other.Extra) return false;
if (ExecutionId != other.ExecutionId) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -1898,6 +1915,7 @@ namespace ScadaLink.Communication.Grpc {
if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode();
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
if (Extra.Length != 0) hash ^= Extra.GetHashCode();
if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -1990,6 +2008,10 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(154, 1);
output.WriteString(Extra);
}
if (ExecutionId.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -2074,6 +2096,10 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(154, 1);
output.WriteString(Extra);
}
if (ExecutionId.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -2141,6 +2167,9 @@ namespace ScadaLink.Communication.Grpc {
if (Extra.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra);
}
if (ExecutionId.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -2217,6 +2246,9 @@ namespace ScadaLink.Communication.Grpc {
if (other.Extra.Length != 0) {
Extra = other.Extra;
}
if (other.ExecutionId.Length != 0) {
ExecutionId = other.ExecutionId;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -2321,6 +2353,10 @@ namespace ScadaLink.Communication.Grpc {
Extra = input.ReadString();
break;
}
case 162: {
ExecutionId = input.ReadString();
break;
}
}
}
#endif
@@ -2425,6 +2461,10 @@ namespace ScadaLink.Communication.Grpc {
Extra = input.ReadString();
break;
}
case 162: {
ExecutionId = input.ReadString();
break;
}
}
}
}
@@ -89,6 +89,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
.HasFilter("[CorrelationId] IS NOT NULL")
.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 })
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
@@ -47,6 +47,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
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.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)
.HasColumnType("nvarchar(1024)");
b.Property<Guid?>("ExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Extra")
.HasColumnType("nvarchar(max)");
@@ -138,6 +141,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("ExecutionId")
.HasDatabaseName("IX_AuditLog_Execution")
.HasFilter("[ExecutionId] IS NOT NULL");
b.HasIndex("OccurredAtUtc")
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
@@ -780,6 +787,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("OriginExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ResolvedTargets")
.HasColumnType("nvarchar(max)");
@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
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.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
@@ -116,25 +116,28 @@ VALUES
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 == siteId);
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
}
if (!string.IsNullOrEmpty(filter.Target))
@@ -154,6 +157,11 @@ VALUES
query = query.Where(e => e.CorrelationId == correlationId);
}
if (filter.ExecutionId is { } executionId)
{
query = query.Where(e => e.ExecutionId == executionId);
}
if (filter.FromUtc is { } fromUtc)
{
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
@@ -260,6 +268,10 @@ VALUES
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) 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)
) ON [PRIMARY];
@@ -84,7 +84,9 @@ public class DatabaseGateway : IDatabaseGateway
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null)
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null)
{
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
if (definition == null)
@@ -124,7 +126,13 @@ public class DatabaseGateway : IDatabaseGateway
// read it back via StoreAndForwardMessage.Id and emit per-attempt +
// terminal cached-write telemetry. Null -> S&F 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-write audit rows carry the same provenance
// the script-side cached rows do.
executionId: executionId,
sourceScript: sourceScript);
}
/// <summary>
@@ -86,7 +86,9 @@ public class ExternalSystemClient : IExternalSystemClient
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null)
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null)
{
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
if (system == null || method == null)
@@ -144,7 +146,13 @@ public class ExternalSystemClient : IExternalSystemClient
// StoreAndForwardMessage.Id and emit per-attempt + terminal
// cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F
// 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);
}
@@ -681,9 +681,10 @@ akka {{
// Per Bundle E's brief: the SiteAuditTelemetryActor takes its
// collaborators through its constructor, so we resolve them from DI
// and pass them in via Props.Create rather than relying on a future
// FactoryProvider. This also lets the M6 follow-up swap the
// NoOpSiteStreamAuditClient registration for the real gRPC client
// without touching this site wiring.
// FactoryProvider. The real site→central client is constructed and
// wired immediately below: a ClusterClientSiteAuditClient (ClusterClient
// transport, not gRPC) replaces the DI-default NoOpSiteStreamAuditClient
// for site roles, without disturbing the rest of this wiring.
var siteAuditOptions = _serviceProvider
.GetRequiredService<IOptions<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>();
var siteAuditQueue = _serviceProvider
+1
View File
@@ -77,6 +77,7 @@
</script>
<script src="/js/treeview-storage.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>
</body>
</html>
@@ -145,6 +145,21 @@ public sealed class AuditWriteMiddleware
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
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,
Target = methodName,
Status = status,
@@ -367,32 +367,26 @@ public static class AuditEndpoints
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. Unknown
/// enum names / un-parseable Guids / dates are silently dropped (no 400) —
/// the same lax contract the CentralUI export endpoint uses.
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
/// 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>
/// <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)
{
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
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;
}
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]);
Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues)
@@ -401,14 +395,22 @@ public static class AuditEndpoints
correlationId = parsedCorr;
}
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: TrimToNullable(query, "sourceSiteId"),
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sourceSiteIds,
Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId,
ExecutionId: executionId,
FromUtc: ParseUtcDate(query, "fromUtc"),
ToUtc: ParseUtcDate(query, "toUtc"));
}
@@ -30,6 +30,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
private const int FallbackMaxRetries = 10;
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 NotificationOutboxOptions _options;
private readonly ICentralAuditWriter _auditWriter;
@@ -482,6 +489,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
/// 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
/// 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>
private static AuditEvent BuildNotifyDeliverEvent(
Notification notification,
@@ -500,12 +511,20 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifyDeliver,
CorrelationId = correlationId,
// Central dispatch — no authenticated actor (the originating
// script's identity is captured on the upstream NotifySend row).
Actor = null,
// Central dispatch — a system identity per the Actor-column spec;
// there is no per-call authenticated user here. The originating
// script is still captured on SourceScript (and on the upstream
// NotifySend row).
Actor = SystemActor,
SourceSiteId = notification.SourceSiteId,
SourceInstanceId = notification.SourceInstanceId,
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,
Status = status,
ErrorMessage = errorMessage,
@@ -932,6 +951,9 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
{
SourceInstanceId = msg.SourceInstanceId,
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,
CreatedAt = DateTimeOffset.UtcNow,
// Status stays at its Pending default for the dispatch sweep to claim.
@@ -32,6 +32,16 @@ public class SiteCallAuditOptions
/// 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 _instanceName;
private readonly string? _sourceScript;
private readonly Guid _executionId;
private readonly ILogger _logger;
private DbConnection? _wrappingConnection;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection).
public AuditingDbCommand(
DbCommand inner,
IAuditWriter auditWriter,
@@ -47,7 +51,8 @@ internal sealed class AuditingDbCommand : DbCommand
string siteId,
string instanceName,
string? sourceScript,
ILogger logger)
ILogger logger,
Guid executionId)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -56,6 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand
_instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
}
// -- Forwarded surface ------------------------------------------------
@@ -426,11 +432,19 @@ internal sealed class AuditingDbCommand : DbCommand
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound,
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,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
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,
Status = status,
HttpStatus = null,
@@ -36,8 +36,12 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _siteId;
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _executionId;
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(
DbConnection inner,
IAuditWriter auditWriter,
@@ -45,7 +49,8 @@ internal sealed class AuditingDbConnection : DbConnection
string siteId,
string instanceName,
string? sourceScript,
ILogger logger)
ILogger logger,
Guid executionId)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -54,6 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
}
// ConnectionString is settable on DbConnection — forward both halves.
@@ -92,7 +98,8 @@ internal sealed class AuditingDbConnection : DbConnection
_siteId,
_instanceName,
_sourceScript,
_logger);
_logger,
_executionId);
}
protected override void Dispose(bool disposing)
@@ -105,6 +105,24 @@ public class ScriptRuntimeContext
/// </summary>
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(
IActorRef instanceActor,
IActorRef self,
@@ -122,7 +140,8 @@ public class ScriptRuntimeContext
string? sourceScript = null,
IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null)
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? executionId = null)
{
_instanceActor = instanceActor;
_self = self;
@@ -141,6 +160,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder;
_executionId = executionId ?? Guid.NewGuid();
}
/// <summary>
@@ -241,7 +261,7 @@ public class ScriptRuntimeContext
/// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary>
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
// on every ExternalSystem.CachedCall enqueue.
_cachedForwarder);
@@ -255,6 +275,7 @@ public class ScriptRuntimeContext
_databaseGateway,
_instanceName,
_logger,
_executionId,
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
// Database.Connection(name) returns an auditing decorator that
// emits one DbOutbound/DbWrite row per script-initiated
@@ -281,7 +302,7 @@ public class ScriptRuntimeContext
/// </remarks>
public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
_auditWriter);
_executionId, _auditWriter);
/// <summary>
/// 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 string _instanceName;
private readonly ILogger _logger;
private readonly Guid _executionId;
private readonly IAuditWriter? _auditWriter;
private readonly string _siteId;
private readonly string? _sourceScript;
@@ -370,10 +392,18 @@ public class ScriptRuntimeContext
// Internal constructor for tests living in ScadaLink.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through
// 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(
IExternalSystemClient? client,
string instanceName,
ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
@@ -382,6 +412,7 @@ public class ScriptRuntimeContext
_client = client;
_instanceName = instanceName;
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
_siteId = siteId;
_sourceScript = sourceScript;
@@ -420,7 +451,7 @@ public class ScriptRuntimeContext
{
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
* 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
// resolve before this method returns.
await EmitCachedSubmitTelemetryAsync(
systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken)
systemName, methodName, target, trackedId, occurredAtUtc, parameters, cancellationToken)
.ConfigureAwait(false);
// Hand off to the existing cached-call path. The TrackedOperationId
@@ -482,7 +513,12 @@ public class ScriptRuntimeContext
parameters,
_instanceName,
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)
{
@@ -503,7 +539,7 @@ public class ScriptRuntimeContext
if (result is { WasBuffered: false })
{
await EmitImmediateTerminalTelemetryAsync(
systemName, methodName, target, trackedId, result, cancellationToken)
systemName, methodName, target, trackedId, result, parameters, cancellationToken)
.ConfigureAwait(false);
}
@@ -521,6 +557,7 @@ public class ScriptRuntimeContext
string target,
TrackedOperationId trackedId,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
@@ -538,12 +575,18 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
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,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
Target = target,
Status = AuditStatus.Submitted,
// Submit precedes the call — request args only, no response yet.
RequestSummary = SerializeRequest(parameters),
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
@@ -599,6 +642,7 @@ public class ScriptRuntimeContext
string target,
TrackedOperationId trackedId,
ExternalCallResult result,
IReadOnlyDictionary<string, object?>? parameters,
CancellationToken cancellationToken)
{
if (_cachedForwarder == null)
@@ -645,7 +689,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -653,6 +700,8 @@ public class ScriptRuntimeContext
Status = AuditStatus.Attempted,
HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
@@ -704,7 +753,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -712,6 +764,8 @@ public class ScriptRuntimeContext
Status = auditTerminalStatus,
HttpStatus = httpStatus,
ErrorMessage = result.Success ? null : result.ErrorMessage,
RequestSummary = SerializeRequest(parameters),
ResponseSummary = result.ResponseJson,
ForwardState = AuditForwardState.Pending,
},
Operational: new SiteCallOperational(
@@ -762,7 +816,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc,
int durationMs,
ExternalCallResult? result,
Exception? thrown)
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{
if (_auditWriter == null)
{
@@ -772,7 +827,8 @@ public class ScriptRuntimeContext
AuditEvent evt;
try
{
evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown);
evt = BuildCallAuditEvent(
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
}
catch (Exception buildEx)
{
@@ -828,7 +884,8 @@ public class ScriptRuntimeContext
DateTime occurredAtUtc,
int durationMs,
ExternalCallResult? result,
Exception? thrown)
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
{
// Status: Delivered on a Success result; Failed otherwise (the
// ExternalSystemClient already maps HTTP non-2xx + transient
@@ -871,24 +928,60 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
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,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
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}",
Status = status,
HttpStatus = httpStatus,
DurationMs = durationMs,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = null,
ResponseSummary = null,
// Payload capture: the request arguments and the response body.
// 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,
Extra = null,
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>
@@ -907,6 +1000,7 @@ public class ScriptRuntimeContext
private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _executionId;
private readonly string _siteId;
private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -923,10 +1017,15 @@ public class ScriptRuntimeContext
/// </summary>
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(
IDatabaseGateway? gateway,
string instanceName,
ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
@@ -935,6 +1034,7 @@ public class ScriptRuntimeContext
_gateway = gateway;
_instanceName = instanceName;
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
_siteId = siteId;
_sourceScript = sourceScript;
@@ -969,7 +1069,8 @@ public class ScriptRuntimeContext
siteId: _siteId,
instanceName: _instanceName,
sourceScript: _sourceScript,
logger: _logger);
logger: _logger,
executionId: _executionId);
}
/// <summary>
@@ -1000,7 +1101,12 @@ public class ScriptRuntimeContext
try
{
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);
}
catch (Exception ex)
@@ -1036,7 +1142,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit,
// CorrelationId = per-operation lifecycle id
// (TrackedOperationId); ExecutionId = per-execution id.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -1098,6 +1207,12 @@ public class ScriptRuntimeContext
private readonly TimeSpan _askTimeout;
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>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script
@@ -1108,6 +1223,8 @@ public class ScriptRuntimeContext
/// </summary>
private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other audit-threaded ctors.
internal NotifyHelper(
StoreAndForwardService? storeAndForward,
ICanTell? siteCommunicationActor,
@@ -1116,6 +1233,7 @@ public class ScriptRuntimeContext
string? sourceScript,
TimeSpan askTimeout,
ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null)
{
_storeAndForward = storeAndForward;
@@ -1125,6 +1243,7 @@ public class ScriptRuntimeContext
_sourceScript = sourceScript;
_askTimeout = askTimeout;
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
}
@@ -1135,6 +1254,9 @@ public class ScriptRuntimeContext
{
return new NotifyTarget(
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()
// can emit one NotifySend(Submitted) row per accepted submission.
_auditWriter);
@@ -1212,6 +1334,12 @@ public class ScriptRuntimeContext
private readonly string? _sourceScript;
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>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after
@@ -1227,6 +1355,7 @@ public class ScriptRuntimeContext
string instanceName,
string? sourceScript,
ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null)
{
_listName = listName;
@@ -1235,6 +1364,7 @@ public class ScriptRuntimeContext
_instanceName = instanceName;
_sourceScript = sourceScript;
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
}
@@ -1277,7 +1407,12 @@ public class ScriptRuntimeContext
// notification, threaded down from the script-execution context for the
// central audit trail. Null when no single script owns the context.
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);
@@ -1351,11 +1486,17 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
// CorrelationId is the NotificationId-derived per-operation
// lifecycle id; ExecutionId carries the per-execution id.
CorrelationId = correlationId,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
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,
Status = AuditStatus.Submitted,
HttpStatus = null,
@@ -55,4 +55,25 @@ public class StoreAndForwardMessage
/// WP-13: Messages are NOT cleared when instance is deleted.
/// </summary>
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
/// inside the payload, and it is the id the forwarder submits to central.
/// </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(
StoreAndForwardCategory category,
string target,
@@ -183,7 +195,9 @@ public class StoreAndForwardService
int? maxRetries = null,
TimeSpan? retryInterval = null,
bool attemptImmediateDelivery = true,
string? messageId = null)
string? messageId = null,
Guid? executionId = null,
string? sourceScript = null)
{
var message = new StoreAndForwardMessage
{
@@ -196,7 +210,9 @@ public class StoreAndForwardService
RetryIntervalMs = (long)(retryInterval ?? _options.DefaultRetryInterval).TotalMilliseconds,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending,
OriginInstanceName = originInstanceName
OriginInstanceName = originInstanceName,
ExecutionId = executionId,
SourceScript = sourceScript
};
// Attempt immediate delivery — unless the caller has already made a
@@ -492,7 +508,14 @@ public class StoreAndForwardService
CreatedAtUtc: message.CreatedAt.UtcDateTime,
OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
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)
{
@@ -65,9 +65,45 @@ public class StoreAndForwardStorage
";
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");
}
/// <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>
/// 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
@@ -105,9 +141,11 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
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,
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError, @origin)";
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
@origin, @executionId, @sourceScript)";
cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category);
@@ -122,6 +160,12 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? 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();
}
@@ -137,7 +181,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
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
WHERE status = @pending
AND (last_attempt_at IS NULL
@@ -268,7 +313,8 @@ public class StoreAndForwardStorage
var categoryFilter = category.HasValue ? " AND category = @category" : "";
pageCmd.CommandText = $@"
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
WHERE status = @parked{categoryFilter}
ORDER BY created_at ASC
@@ -389,7 +435,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
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
WHERE id = @id";
cmd.Parameters.AddWithValue("@id", messageId);
@@ -446,9 +493,35 @@ public class StoreAndForwardStorage
LastAttemptAt = reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)),
Status = (StoreAndForwardMessageStatus)reader.GetInt32(9),
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;
}
/// <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;
}
}
@@ -150,6 +150,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
client,
instanceName: "Plant.Pump42",
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Sync",
@@ -193,6 +194,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
client,
instanceName: "Plant.Pump42",
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Cached",
@@ -243,6 +245,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
gateway,
instanceName,
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: "site-77",
sourceScript: "ScriptActor:Db",
@@ -157,6 +157,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
gateway,
InstanceName,
NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer,
siteId: siteId,
sourceScript: SourceScript,
@@ -214,7 +215,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
@@ -282,7 +283,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
@@ -0,0 +1,274 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the
/// universal per-run correlation promise: <b>every audit row produced by one
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
/// </summary>
/// <remarks>
/// <para>
/// This is the integration-level counterpart to the unit-level
/// <c>ExecutionCorrelationContextTests</c> in <c>ScadaLink.SiteRuntime.Tests</c>:
/// where that test asserts the shared id on the in-memory captured rows, this
/// suite drives the rows all the way through the production pipeline — the real
/// <see cref="SqliteAuditWriter"/> site hot-path, the real
/// <see cref="SiteAuditTelemetryActor"/> drain loop, the real
/// <see cref="AuditLogIngestActor"/>, and the real <see cref="AuditLogRepository"/>
/// over the per-class <see cref="MsSqlMigrationFixture"/> MSSQL database — then
/// reads the rows back from the central store and asserts the shared id.
/// </para>
/// <para>
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>
/// and the M4 <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
/// <see cref="SiteAuditTelemetryActor"/> through the shared
/// <see cref="DirectActorSiteStreamAuditClient"/> stub that short-circuits the
/// gRPC wire and Asks the central ingest actor. The production
/// <see cref="ScriptRuntimeContext"/> is driven directly: one context performs
/// two distinct trust-boundary actions — a sync <c>ExternalSystem.Call</c> and a
/// sync <c>Database</c> write — so the two emitted audit rows originate from one
/// execution. Each test uses a unique <c>ExecutionId</c> + <c>SourceSiteId</c>
/// (Guid suffixes) so concurrent tests sharing the MSSQL fixture don't interfere.
/// </para>
/// </remarks>
public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private const string ConnectionName = "machineData";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:OnTick";
private static string NewSiteId() =>
"test-execid-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaLinkDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaLinkDbContext(options);
}
/// <summary>
/// Per-test in-memory SQLite database with a tiny single-table schema the
/// sync DB write targets. The keep-alive root pins the in-memory file for
/// the duration of the test; the returned <c>live</c> connection is what the
/// stub gateway hands back to the auditing wrapper. Mirrors
/// <c>DatabaseSyncEmissionEndToEndTests.NewInMemoryDb</c>.
/// </summary>
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
{
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
using (var seed = keepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
seed.ExecuteNonQuery();
}
var live = new SqliteConnection(connStr);
live.Open();
return live;
}
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
new(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
connectionStringOverride:
$"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared");
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = 256,
// 1s on both intervals so the initial scheduled tick fires quickly
// — drains the SQLite Pending rows and pushes them through the stub
// gRPC client into the central ingest actor.
BusyIntervalSeconds = 1,
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
NullLogger<AuditLogIngestActor>.Instance)));
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
ISiteStreamAuditClient client) =>
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
queue,
client,
FastTelemetryOptions(),
NullLogger<SiteAuditTelemetryActor>.Instance)));
[SkippableFact]
public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// An explicit per-run execution id — the value the test asserts on every
// audit row produced by the single script execution below.
var executionId = Guid.NewGuid();
// ── Central — repository + ingest actor backed by the MSSQL fixture ──
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
// ── Site — SQLite audit writer + ring + fallback + telemetry actor ───
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
CreateTelemetryActor(sqliteWriter, stubClient);
// Outbound API client — one successful CallAsync, one audit row.
var externalClient = Substitute.For<IExternalSystemClient>();
externalClient
.CallAsync("ERP", "GetOrder", Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new ExternalCallResult(true, "{}", null));
// SQLite-backed inner DB connection — the stub gateway hands it to the
// auditing wrapper as the connection the script would have got.
using var keepAlive = new SqliteConnection("Data Source=execid-k1;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out _);
var gateway = Substitute.For<IDatabaseGateway>();
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
.Returns(innerDb);
// ── Act — ONE script execution: a sync ExternalSystem.Call AND a sync
// Database write, both performed through a SINGLE ScriptRuntimeContext
// stamped with the explicit executionId. Each helper emits exactly one
// trust-boundary audit row to the fallback writer; the telemetry actor's
// next tick drains both to central.
var context = CreateScriptContext(externalClient, gateway, fallback, siteId, executionId);
await context.ExternalSystem.Call("ERP", "GetOrder");
await using (var conn = await context.Database.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
var affected = await cmd.ExecuteNonQueryAsync();
Assert.Equal(1, affected);
}
// ── Assert — read the rows back from the CENTRAL store filtered by the
// execution id; both the ApiCall and the DbWrite row must be present and
// every one must carry the SAME non-null ExecutionId we minted above.
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
// The ExecutionId filter dimension is the universal-correlation
// query an audit reader uses to pull every action of one run.
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(ExecutionId: executionId),
new AuditLogPaging(PageSize: 10));
// Both trust-boundary actions of the one execution have landed.
Assert.Equal(2, rows.Count);
// Every central row carries the SAME non-null ExecutionId — the
// core promise of the per-run correlation value.
Assert.All(rows, r =>
{
Assert.NotNull(r.ExecutionId);
Assert.Equal(executionId, r.ExecutionId);
Assert.Equal(siteId, r.SourceSiteId);
// Central stamps IngestedAtUtc; the site never sets it.
Assert.NotNull(r.IngestedAtUtc);
});
// The two rows are the two distinct trust-boundary actions — one
// outbound API call and one outbound DB write — proving the shared
// id spans different channels, not two rows of the same action.
Assert.Single(rows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
Assert.Single(rows, r => r.Channel == AuditChannel.DbOutbound && r.Kind == AuditKind.DbWrite);
}, TimeSpan.FromSeconds(15));
}
/// <summary>
/// Builds a production <see cref="ScriptRuntimeContext"/> wired with the
/// outbound external-system client, the database gateway and the audit
/// writer, stamped with an explicit <paramref name="executionId"/>. The
/// actor refs are <see cref="ActorRefs.Nobody"/> — the ExternalSystem /
/// Database helpers exercised here never touch them.
/// </summary>
private static ScriptRuntimeContext CreateScriptContext(
IExternalSystemClient externalSystemClient,
IDatabaseGateway databaseGateway,
IAuditWriter auditWriter,
string siteId,
Guid executionId)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: InstanceName,
logger: NullLogger.Instance,
externalSystemClient: externalSystemClient,
databaseGateway: databaseGateway,
storeAndForward: null,
siteCommunicationActor: null,
siteId: siteId,
sourceScript: SourceScript,
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
executionId: executionId);
}
}
@@ -241,7 +241,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50));
// 1 Submit + 1 Attempted = 2 rows so far.
Assert.Equal(2, rows.Count);
@@ -257,7 +257,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50));
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
Assert.InRange(rows.Count, 3, 4);
@@ -160,7 +160,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId);
@@ -207,7 +207,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId);
@@ -260,7 +260,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(sharedId, rows[0].EventId);
@@ -2,6 +2,8 @@ using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Site;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Site;
@@ -41,9 +43,9 @@ public class SqliteAuditWriterSchemaTests
}
[Fact]
public void Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId()
public void Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId));
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
@@ -57,7 +59,7 @@ public class SqliteAuditWriterSchemaTests
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
Assert.Equal(20, columns.Count);
Assert.Equal(21, columns.Count);
var expected = new[]
{
@@ -65,7 +67,7 @@ public class SqliteAuditWriterSchemaTests
"SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
"ForwardState",
"ForwardState", "ExecutionId",
};
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
@@ -125,4 +127,122 @@ public class SqliteAuditWriterSchemaTests
Assert.Equal(2, value);
}
}
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The OLD pre-ExecutionId-branch <c>AuditLog</c> schema — the 20-column
/// CREATE TABLE WITHOUT the <c>ExecutionId</c> column. A real deployment's
/// on-disk <c>auditlog.db</c> already contains exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreExecutionIdSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the OLD 20-column schema and
/// returns the open connection. The connection MUST stay open for the
/// lifetime of the test: a shared-cache in-memory database is dropped once
/// its last connection closes, so closing this would discard the seeded
/// schema before the writer opens its own connection.
/// </summary>
private static SqliteConnection SeedOldSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
private static SqliteAuditWriter CreateWriterOver(string dataSource)
{
var options = new SqliteAuditWriterOptions { DatabasePath = dataSource };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}
private static bool ColumnExists(SqliteConnection connection, string columnName)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
cmd.Parameters.AddWithValue("$name", columnName);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
[Fact]
public async Task Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A pre-branch deployment: auditlog.db already exists with the 20-column
// schema and NO ExecutionId column.
using var seedConnection = SeedOldSchemaDatabase(dataSource);
Assert.False(ColumnExists(seedConnection, "ExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ExecutionId column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
var executionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ExecutionId"),
"SqliteAuditWriter must ALTER the ExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
// without the ALTER it would fail with "no such column: ExecutionId"
// and — because audit writes are best-effort — silently drop the row.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
ExecutionId = executionId,
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
}
}
}
@@ -353,4 +353,37 @@ public class SqliteAuditWriterWriteTests
await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() });
// Completes without throwing.
}
// ----- ExecutionId column (universal per-run correlation value) ----- //
[Fact]
public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow));
await using var _w = writer;
var executionId = Guid.NewGuid();
var evt = NewEvent() with { ExecutionId = executionId };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
}
[Fact]
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
await using var _w = writer;
var evt = NewEvent() with { ExecutionId = null };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.ExecutionId);
}
}
@@ -31,7 +31,9 @@ public class CachedCallLifecycleBridgeTests
string channel = "ApiOutbound",
int retryCount = 1,
string? lastError = null,
int? httpStatus = null) =>
int? httpStatus = null,
Guid? executionId = null,
string? sourceScript = null) =>
new(
TrackedOperationId: _id,
Channel: channel,
@@ -44,7 +46,9 @@ public class CachedCallLifecycleBridgeTests
CreatedAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
OccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
DurationMs: 42,
SourceInstanceId: "Plant.Pump42");
SourceInstanceId: "Plant.Pump42",
ExecutionId: executionId,
SourceScript: sourceScript);
[Fact]
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
@@ -184,4 +188,75 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(42, captured.Audit.DurationMs);
Assert.Equal(_id.Value, captured.Audit.CorrelationId);
}
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
[Fact]
public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext()
{
// Task 4: the ExecutionId + SourceScript threaded through the S&F
// buffer arrive on the CachedCallAttemptContext; the bridge must stamp
// both onto the per-attempt ApiCallCached row (previously SourceScript
// was hard-coded null with a "not threaded through S&F" comment).
var executionId = Guid.NewGuid();
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.TransientFailure,
executionId: executionId,
sourceScript: "Plant.Pump42/OnTick"));
var packet = Assert.Single(captured);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(executionId, packet.Audit.ExecutionId);
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.SourceScript);
}
[Fact]
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
{
// The terminal CachedResolve row must also carry the threaded
// provenance so the whole retry-loop lifecycle is correlated.
var executionId = Guid.NewGuid();
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.Delivered,
channel: "DbOutbound",
executionId: executionId,
sourceScript: "Plant.Tank/OnAlarm"));
Assert.Equal(2, captured.Count);
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
Assert.Equal(executionId, resolve.Audit.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.SourceScript);
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
Assert.Equal(executionId, attempted.Audit.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.SourceScript);
}
[Fact]
public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull()
{
// Back-compat: a pre-Task-4 buffered row has no ExecutionId /
// SourceScript; the bridge must leave the audit row's fields null
// rather than throwing.
CachedCallTelemetry? captured = null;
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
Assert.NotNull(captured);
Assert.Null(captured!.Audit.ExecutionId);
Assert.Null(captured.Audit.SourceScript);
}
}
@@ -63,8 +63,8 @@ public class AuditExportCommandTests
Until = "2026-05-20T12:00:00Z",
Format = "jsonl",
Output = "/tmp/x",
Channel = "Notification",
Site = "site-9",
Channel = new[] { "Notification" },
Site = new[] { "site-9" },
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
@@ -76,6 +76,90 @@ public class AuditExportCommandTests
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
}
[Fact]
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditExportArgs
{
Since = "1h",
Until = "2026-05-20T12:00:00Z",
Format = "csv",
Output = "/tmp/x",
Channel = new[] { "ApiOutbound", "DbOutbound" },
Kind = new[] { "ApiCall", "DbWrite" },
Status = new[] { "Failed", "Parked" },
Site = new[] { "site-1", "site-2" },
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
Assert.Equal(new[] { "ApiCall", "DbWrite" }, parsed.GetValues("kind"));
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
}
[Fact]
public void BuildQueryString_OmitsUnsetMultiValueFilters()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditExportArgs
{
Since = "1h",
Until = "0h",
Format = "csv",
Output = "/tmp/x",
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Null(parsed["channel"]);
Assert.Null(parsed["kind"]);
Assert.Null(parsed["status"]);
Assert.Null(parsed["sourceSiteId"]);
}
[Fact]
public void Export_MultipleChannelValues_SingleToken_AreAccepted()
{
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "DbOutbound",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "--channel", "Notification",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Export_MultiValueChannel_WithOneInvalidName_FailsFast()
{
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(
root, "audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "OutboundApi");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
// ---- Streaming export to file -----------------------------------------
private sealed class BodyHandler : HttpMessageHandler
@@ -58,13 +58,14 @@ public class AuditQueryCommandTests
{
Since = "1h",
Until = "2026-05-20T12:00:00Z",
Channel = "ApiOutbound",
Kind = "ApiCallCached",
Status = "Delivered",
Site = "site-1",
Channel = new[] { "ApiOutbound" },
Kind = new[] { "ApiCallCached" },
Status = new[] { "Delivered" },
Site = new[] { "site-1" },
Target = "weather-api",
Actor = "multi-role",
CorrelationId = "abc-123",
ExecutionId = "def-456",
ErrorsOnly = false,
PageSize = 250,
};
@@ -76,11 +77,12 @@ public class AuditQueryCommandTests
Assert.Equal("ApiCallCached", parsed["kind"]);
Assert.Equal("Delivered", parsed["status"]);
Assert.Equal("site-1", parsed["sourceSiteId"]);
// --instance was dropped: AuditLogQueryFilter has no instance column.
// The CLI audit query has no --instance flag, so no instance param is emitted.
Assert.Null(parsed["instance"]);
Assert.Equal("weather-api", parsed["target"]);
Assert.Equal("multi-role", parsed["actor"]);
Assert.Equal("abc-123", parsed["correlationId"]);
Assert.Equal("def-456", parsed["executionId"]);
Assert.Equal("250", parsed["pageSize"]);
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
@@ -96,6 +98,43 @@ public class AuditQueryCommandTests
Assert.Equal("Failed", parsed["status"]);
}
[Fact]
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs
{
Channel = new[] { "ApiOutbound", "DbOutbound" },
Status = new[] { "Failed", "Parked" },
Site = new[] { "site-1", "site-2" },
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
}
[Fact]
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
{
// --errors-only stays a single-status override: it pins status=Failed and
// supersedes any explicit (multi-value) --status selection.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs
{
ErrorsOnly = true,
Status = new[] { "Delivered", "Parked" },
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
}
[Fact]
public void BuildQueryString_Cursor_AppendsAfterParameters()
{
@@ -118,9 +157,22 @@ public class AuditQueryCommandTests
Assert.Null(parsed["channel"]);
Assert.Null(parsed["status"]);
Assert.Null(parsed["fromUtc"]);
Assert.Null(parsed["correlationId"]);
Assert.Null(parsed["executionId"]);
Assert.Equal("100", parsed["pageSize"]);
}
[Fact]
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
{
// --execution-id is a single-value Guid filter — mirrors --correlation-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
}
// ---- HTTP execution / paging ------------------------------------------
private sealed class RecordingHandler : HttpMessageHandler
@@ -244,6 +296,18 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_ExecutionIdOption_IsAccepted()
{
// --execution-id is a single-value option — mirrors --correlation-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111",
});
Assert.Empty(parse.Errors);
}
// ---- Enum-name validation (fast-fail) ----------------------------------
[Fact]
@@ -254,6 +318,38 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
{
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "DbOutbound" });
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
{
// --channel A --channel B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--channel", "ApiOutbound", "--channel", "Notification",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultiValueChannel_WithOneInvalidName_FailsFast()
{
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(
root, "audit", "query", "--channel", "ApiOutbound", "OutboundApi");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
[Fact]
public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit()
{
@@ -65,6 +65,7 @@ internal static class AuditDataSeeder
string? target = null,
string? actor = null,
Guid? correlationId = null,
Guid? executionId = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
@@ -76,13 +77,13 @@ internal static class AuditDataSeeder
const string sql = @"
INSERT INTO [AuditLog]
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
[SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status],
[HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
[Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
VALUES
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
@sourceSiteId, NULL, NULL, @actor, @target, @status,
@httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@executionId, @sourceSiteId, NULL, NULL, @actor, @target,
@status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@responseSummary, 0, @extra, NULL);";
await using var connection = new SqlConnection(ConnectionString);
@@ -94,6 +95,7 @@ VALUES
cmd.Parameters.AddWithValue("@channel", channel);
cmd.Parameters.AddWithValue("@kind", kind);
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);
@@ -0,0 +1,281 @@
using Microsoft.Playwright;
using Xunit;
namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// <summary>
/// End-to-end coverage for the Audit Log results-grid column UX (#23
/// follow-ups Task 10): drag-to-resize and drag-to-reorder columns, with the
/// chosen widths + order persisted in the browser's <c>sessionStorage</c>.
///
/// <para>
/// The drag interaction is browser-side (<c>wwwroot/js/audit-grid.js</c>), so
/// Playwright — not bUnit — is the right tool: bUnit cannot drive the native
/// HTML5 drag-and-drop or pointer-capture resize. Each test seeds one
/// <c>AuditLog</c> row via <see cref="AuditDataSeeder"/> so the grid has a
/// header row to act on, then best-effort deletes it.
/// </para>
///
/// <para>
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
/// matching the established <see cref="SiteCalls.SiteCallsPageTests"/> idiom.
/// </para>
/// </summary>
[Collection("Playwright")]
public class AuditGridColumnTests
{
private const string AuditLogUrl = "/audit/log";
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
private const string DbUnavailableSkipReason =
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
private readonly PlaywrightFixture _fixture;
public AuditGridColumnTests(PlaywrightFixture fixture)
{
_fixture = fixture;
}
/// <summary>
/// Seeds one audit row, opens the Audit Log page, and clicks Apply so the
/// results grid renders a header row the column tests can act on.
/// </summary>
private async Task<IPage> OpenGridWithSeededRowAsync(string targetPrefix, Guid eventId)
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: DateTime.UtcNow,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "endpoint",
httpStatus: 200,
durationMs: 25);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{AuditLogUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Apply with no chips — the default LastHour range matches the fresh row.
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var row = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(row).ToBeVisibleAsync();
return page;
}
/// <summary>Pixel width of a header cell, measured from its bounding box.</summary>
private static async Task<double> HeaderWidthAsync(IPage page, string columnKey)
{
var box = await page.Locator($"[data-col-key='{columnKey}']").BoundingBoxAsync();
Assert.NotNull(box);
return box!.Width;
}
/// <summary>The ordered list of column keys as currently rendered in the header.</summary>
private static async Task<IReadOnlyList<string>> HeaderOrderAsync(IPage page)
{
return await page.Locator("thead th[data-col-key]")
.EvaluateAllAsync<string[]>("els => els.map(e => e.getAttribute('data-col-key'))");
}
/// <summary>
/// Polls until <paramref name="storageKey"/> has been written to
/// <c>sessionStorage</c>. The grid persists a resize/reorder
/// asynchronously — the browser-side drag fires a fire-and-forget
/// JS→.NET invoke (<c>OnColumnResized</c>/<c>OnColumnReordered</c>), and
/// the .NET handler then round-trips back through JS interop to write
/// <c>sessionStorage</c>. A bare <c>getItem</c> immediately after the drag
/// races that round-trip; this waits for the key to actually land.
/// </summary>
private static async Task WaitForStorageKeyAsync(IPage page, string storageKey)
{
await page.WaitForFunctionAsync(
"key => sessionStorage.getItem(key) !== null", storageKey);
}
/// <summary>
/// Polls until the header's first column key equals <paramref name="expectedFirstKey"/>.
/// A drag-to-reorder re-renders the header asynchronously (the JS→.NET
/// <c>OnColumnReordered</c> invoke is fire-and-forget), so reading the
/// header order synchronously after <c>DragToAsync</c> can observe the
/// pre-reorder layout. This waits for the re-render to settle.
/// </summary>
private static async Task WaitForFirstColumnAsync(IPage page, string expectedFirstKey)
{
await page.WaitForFunctionAsync(
"key => { var th = document.querySelector('thead th[data-col-key]'); " +
"return th && th.getAttribute('data-col-key') === key; }",
expectedFirstKey);
}
[SkippableFact]
public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload()
{
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/grid-resize/{runId}/";
var eventId = Guid.NewGuid();
try
{
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
const string columnKey = "Target";
var before = await HeaderWidthAsync(page, columnKey);
// Drag the resize handle on the column's right edge 120px to the
// right. The handle is a thin strip; grab its centre and drag.
var handle = page.Locator($"[data-test='col-resize-{columnKey}']");
var handleBox = await handle.BoundingBoxAsync();
Assert.NotNull(handleBox);
var startX = handleBox!.X + handleBox.Width / 2;
var startY = handleBox.Y + handleBox.Height / 2;
await page.Mouse.MoveAsync(startX, startY);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(startX + 120, startY, new MouseMoveOptions { Steps = 8 });
await page.Mouse.UpAsync();
var after = await HeaderWidthAsync(page, columnKey);
Assert.True(after > before + 40,
$"Expected the {columnKey} column to widen after the resize drag (before={before}, after={after}).");
// Reload: the persisted width is restored from sessionStorage.
await page.ReloadAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var afterReload = await HeaderWidthAsync(page, columnKey);
// Allow a small tolerance for sub-pixel layout rounding.
Assert.True(Math.Abs(afterReload - after) < 8,
$"Expected the resized width to survive a reload (after={after}, afterReload={afterReload}).");
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[SkippableFact]
public async Task ReorderDrag_MovesColumn_AndSurvivesReload()
{
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/grid-reorder/{runId}/";
var eventId = Guid.NewGuid();
try
{
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
var initialOrder = await HeaderOrderAsync(page);
// Default order opens with OccurredAtUtc first, Status fifth.
Assert.Equal("OccurredAtUtc", initialOrder[0]);
Assert.Contains("Status", initialOrder);
// Drag the Status header onto the OccurredAtUtc header — Status
// should move into the leading slot.
var source = page.Locator("[data-col-key='Status']");
var target = page.Locator("[data-col-key='OccurredAtUtc']");
await source.DragToAsync(target);
// The reorder re-renders the header asynchronously (fire-and-forget
// JS→.NET invoke); wait for it to settle before reading the order.
await WaitForFirstColumnAsync(page, "Status");
var afterOrder = await HeaderOrderAsync(page);
Assert.Equal("Status", afterOrder[0]);
Assert.True(afterOrder.ToList().IndexOf("Status") < afterOrder.ToList().IndexOf("OccurredAtUtc"),
"Expected Status to be reordered ahead of OccurredAtUtc.");
// Reload: the persisted order is restored from sessionStorage on
// the grid's first render — wait for the header to reflect it.
await page.ReloadAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await WaitForFirstColumnAsync(page, "Status");
var afterReload = await HeaderOrderAsync(page);
Assert.Equal("Status", afterReload[0]);
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[SkippableFact]
public async Task ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage()
{
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/grid-persist/{runId}/";
var eventId = Guid.NewGuid();
try
{
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
// Reorder then resize, then confirm sessionStorage carries both.
await page.Locator("[data-col-key='Status']")
.DragToAsync(page.Locator("[data-col-key='OccurredAtUtc']"));
// Wait for the reorder re-render to settle before measuring the
// resize handle, so the handle's bounding box is read off the
// post-reorder layout.
await WaitForFirstColumnAsync(page, "Status");
var handle = page.Locator("[data-test='col-resize-Target']");
var handleBox = await handle.BoundingBoxAsync();
Assert.NotNull(handleBox);
var startX = handleBox!.X + handleBox.Width / 2;
var startY = handleBox.Y + handleBox.Height / 2;
await page.Mouse.MoveAsync(startX, startY);
await page.Mouse.DownAsync();
await page.Mouse.MoveAsync(startX + 90, startY, new MouseMoveOptions { Steps = 6 });
await page.Mouse.UpAsync();
// Both keys are written under the auditGrid: namespace — but the
// write is asynchronous: pointer-up fires a fire-and-forget
// OnColumnResized/OnColumnReordered JS→.NET invoke, and the .NET
// handler then round-trips back through JS interop to call
// auditGrid.save. Reading sessionStorage synchronously right after
// Mouse.UpAsync races that round-trip, so poll for both keys to
// land before asserting on them.
await WaitForStorageKeyAsync(page, "auditGrid:columnOrder");
await WaitForStorageKeyAsync(page, "auditGrid:columnWidths");
var orderJson = await page.EvaluateAsync<string?>(
"() => sessionStorage.getItem('auditGrid:columnOrder')");
var widthsJson = await page.EvaluateAsync<string?>(
"() => sessionStorage.getItem('auditGrid:columnWidths')");
Assert.NotNull(orderJson);
Assert.Contains("Status", orderJson!);
Assert.NotNull(widthsJson);
Assert.Contains("Target", widthsJson!);
// After a reload the restored grid reflects the stored order. The
// restore happens on the grid's first render (LoadPersistedState →
// StateHasChanged), so wait for the header to reflect it.
await page.ReloadAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await WaitForFirstColumnAsync(page, "Status");
var restoredOrder = await HeaderOrderAsync(page);
Assert.Equal("Status", restoredOrder[0]);
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
}
@@ -16,7 +16,7 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// <para>
/// Scenarios covered (per the M7-T16 brief):
/// <list type="bullet">
/// <item><c>FilterNarrowing</c> — channel chip narrows the results grid.</item>
/// <item><c>FilterNarrowing</c> — the channel filter narrows the results grid.</item>
/// <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item>
/// <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item>
/// <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — query-string drill-in
@@ -24,6 +24,9 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// link relies on; verified by reproducing the link target directly because
/// seeding a notification visible to the report page requires the Akka query
/// path, not just an INSERT).</item>
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
/// auto-loads the grid filtered by ExecutionId.</item>
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
/// the report page wires drill-in links when notifications are present.</item>
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
@@ -45,7 +48,7 @@ public class AuditLogPageTests
}
[Fact]
public async Task FilterNarrowing_ChannelChipShrinksGrid()
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
{
// Skip with a clear message when MSSQL is not reachable — the rest of
// the Playwright suite is UI-only and does not need the DB, so this
@@ -91,13 +94,13 @@ public class AuditLogPageTests
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Pre-Apply, both rows are absent because the grid stays empty until
// the user filters. Click the ApiOutbound chip then Apply.
await page.Locator("[data-test='chip-channel-ApiOutbound']").ClickAsync();
// the user filters. Pick the ApiOutbound channel, then Apply.
await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
await page.Locator("[data-test='filter-apply']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The seeded ApiOutbound row is visible; the DbOutbound row is not
// (it was filtered out by the channel chip).
// (it was filtered out by the channel filter).
var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
await Assertions.Expect(apiRow).ToBeVisibleAsync();
@@ -289,6 +292,64 @@ public class AuditLogPageTests
}
}
[Fact]
public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext()
{
// Mirrors the correlationId drill-in: the "View this execution" drawer
// action navigates to /audit/log?executionId={ExecutionId}. We seed a row
// carrying that ExecutionId, hit the deep link directly, and assert the
// page deserializes the param and auto-loads the seeded row.
if (!await AuditDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/exec-drill-in/{runId}/";
var executionId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "endpoint",
executionId: executionId,
httpStatus: 200,
durationMs: 11);
var page = await _fixture.NewAuthenticatedPageAsync();
// The exact URL the drawer's "View this execution" button produces.
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={executionId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains($"executionId={executionId}", page.Url);
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();
// Auto-load: the query-string drill-in resolves the ?executionId=
// filter on OnInitialized and the seeded row appears without an
// Apply click.
var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(seededRow).ToBeVisibleAsync();
// The ExecutionId column renders the row's short-form value.
var execCell = page.Locator($"[data-test='execution-id-{eventId}']");
await Assertions.Expect(execCell).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
{
@@ -51,6 +51,40 @@ public class SiteCallsPageTests
_fixture = fixture;
}
/// <summary>
/// Sets the Target-keyword search box and commits the value to the server
/// as its own discrete circuit message before the caller clicks Query.
/// <para>
/// The <c>#sc-search</c> input is a Blazor <c>@bind</c>
/// (commit-on-<c>change</c>): <see cref="ILocator.FillAsync"/> only fires
/// <c>input</c> events, and the <c>change</c> that actually updates
/// <c>_targetFilter</c> on the server fires on blur. The original test
/// relied on the Query <c>ClickAsync</c> itself to blur the field — that
/// makes the <c>change</c> (blur) and the <c>click</c> a single, near-
/// simultaneous gesture and races them over the SignalR circuit: when the
/// <c>click</c> is processed before the <c>change</c> has updated
/// <c>_targetFilter</c>, <c>Search()</c> runs with a stale (empty) keyword
/// and the grid returns unfiltered rows.
/// </para>
/// <para>
/// <see cref="ILocator.DispatchEventAsync"/> raises the <c>change</c> as a
/// fully-awaited action of its own, so its circuit message is enqueued and
/// sent before the later Query <c>ClickAsync</c>'s message. The SignalR
/// connection delivers messages in send order and the Blazor circuit
/// processes them sequentially, so <c>_targetFilter</c> is guaranteed
/// committed before <c>Search()</c> runs — the two are no longer one
/// racing gesture.
/// </para>
/// </summary>
private static async Task SetSearchKeywordAsync(IPage page, string keyword)
{
var search = page.Locator("#sc-search");
await search.FillAsync(keyword);
// Commit the @bind as a discrete change event — not a blur side effect
// of the subsequent Query click.
await search.DispatchEventAsync("change");
}
[Fact]
public async Task PageLoads_ForDeploymentUser()
{
@@ -98,22 +132,26 @@ public class SiteCallsPageTests
// Unfiltered query: both seeded rows appear (the Target keyword scopes
// to this run so unrelated cluster rows do not interfere).
await page.Locator("#sc-search").FillAsync(targetPrefix + "api");
await SetSearchKeywordAsync(page, targetPrefix + "api");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Only the ApiOutbound row matches the exact target keyword.
// Only the ApiOutbound row matches the exact target keyword. The
// grid filters with an exact Target match, so the db row must be
// absent — use the retrying ToHaveCount assertion so the negative
// check waits out the post-query re-render rather than reading a
// point-in-time count.
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync();
Assert.Equal(0, await page.Locator($"text={targetPrefix}db").CountAsync());
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToHaveCountAsync(0);
// Now filter by Channel = DbOutbound with the db target — the row flips.
await page.Locator("#sc-search").FillAsync(targetPrefix + "db");
await SetSearchKeywordAsync(page, targetPrefix + "db");
await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync();
Assert.Equal(0, await page.Locator($"text={targetPrefix}api").CountAsync());
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToHaveCountAsync(0);
}
finally
{
@@ -142,7 +180,7 @@ public class SiteCallsPageTests
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("#sc-search").FillAsync(targetPrefix + "endpoint");
await SetSearchKeywordAsync(page, targetPrefix + "endpoint");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
@@ -199,7 +237,7 @@ public class SiteCallsPageTests
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Query the parked row first.
await page.Locator("#sc-search").FillAsync(targetPrefix + "parked");
await SetSearchKeywordAsync(page, targetPrefix + "parked");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
@@ -210,7 +248,7 @@ public class SiteCallsPageTests
await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();
// Now the Failed row — Retry/Discard are absent.
await page.Locator("#sc-search").FillAsync(targetPrefix + "failed");
await SetSearchKeywordAsync(page, targetPrefix + "failed");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
@@ -238,10 +276,18 @@ public class SiteCallsPageTests
try
{
// A single Parked row — the only status from which Retry/Discard can
// be relayed to the owning site.
// be relayed to the owning site. Unlike the display-only tests above,
// this one actually relays to the owning site, so the SourceSite must
// be a *real* site identifier from the running cluster (site-a) and
// not the cosmetic "plant-a" label: an unknown site has no registered
// ClusterClient, so CentralCommunicationActor drops the envelope
// without replying and the relay only resolves on the 10s inner Ask
// timeout — too slow for the toast assertion below. Relayed to a live
// site, the site finds no parked S&F message for this freshly-seeded
// GUID and replies a fast NotParked ack, which still surfaces a toast.
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
sourceSite: "plant-a", status: "Parked", retryCount: 3,
sourceSite: "site-a", status: "Parked", retryCount: 3,
lastError: "HTTP 503 from ERP", httpStatus: 503,
createdAtUtc: now, updatedAtUtc: now);
@@ -249,7 +295,7 @@ public class SiteCallsPageTests
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("#sc-search").FillAsync(targetPrefix + "parked");
await SetSearchKeywordAsync(page, targetPrefix + "parked");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
@@ -269,9 +315,14 @@ public class SiteCallsPageTests
// the owning site is offline in this environment, SiteUnreachable.
// We only assert that an outcome toast appears (exactly one — the
// single-toast contract), not which one, since the live cluster
// state determines the outcome.
// state determines the outcome. The wait is generous (15s): the
// relay round-trips to the site over ClusterClient, and a worst-case
// path can sit on the 10s inner relay timeout before the response —
// and the toast itself auto-dismisses 5s after it appears, so the
// assertion must catch it inside that window.
var toast = page.Locator(".toast");
await Assertions.Expect(toast).ToBeVisibleAsync();
await Assertions.Expect(toast).ToBeVisibleAsync(
new() { Timeout = 15_000 });
Assert.Equal(1, await toast.CountAsync());
}
finally
@@ -138,6 +138,7 @@ public class AuditExportEndpointsTests
using (host)
{
var correlationId = Guid.NewGuid().ToString();
var executionId = Guid.NewGuid().ToString();
var url =
"/api/centralui/audit/export?" +
"channel=ApiOutbound&" +
@@ -147,6 +148,7 @@ public class AuditExportEndpointsTests
"target=PaymentApi&" +
"actor=apikey-1&" +
$"correlationId={correlationId}&" +
$"executionId={executionId}&" +
"from=2026-05-20T00:00:00Z&" +
"to=2026-05-20T23:59:59Z";
@@ -160,13 +162,14 @@ public class AuditExportEndpointsTests
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == AuditChannel.ApiOutbound &&
f.Kind == AuditKind.ApiCall &&
f.Status == AuditStatus.Failed &&
f.SourceSiteId == "plant-a" &&
f.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound &&
f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall &&
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed &&
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" &&
f.Target == "PaymentApi" &&
f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) &&
f.ExecutionId == Guid.Parse(executionId) &&
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
Arg.Any<AuditLogPaging>(),
@@ -188,13 +191,14 @@ public class AuditExportEndpointsTests
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == null &&
f.Kind == null &&
f.Status == null &&
f.SourceSiteId == null &&
f.Channels == null &&
f.Kinds == null &&
f.Statuses == null &&
f.SourceSiteIds == null &&
f.Target == null &&
f.Actor == null &&
f.CorrelationId == null &&
f.ExecutionId == null &&
f.FromUtc == null &&
f.ToUtc == null),
Arg.Any<AuditLogPaging>(),
@@ -216,7 +220,26 @@ public class AuditExportEndpointsTests
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Channel == null),
Arg.Is<AuditLogQueryFilter>(f => f.Channels == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped()
{
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
// mirrors the correlationId parse.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
@@ -40,6 +40,7 @@ public class AuditDrilldownDrawerTests : BunitContext
string? responseSummary = null,
string? extra = null,
Guid? correlationId = null,
Guid? executionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
@@ -51,6 +52,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Channel = channel,
Kind = kind,
CorrelationId = correlationId,
ExecutionId = executionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
@@ -216,6 +218,46 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains(corr.ToString(), nav.Uri);
}
[Fact]
public void Drawer_NullExecutionId_HidesViewThisExecutionButton()
{
var ev = MakeEvent(executionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton()
{
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
}
[Fact]
public void ViewThisExecution_Navigates_WithExecutionIdQueryString()
{
var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-this-execution\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
}
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
@@ -13,13 +13,18 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
///
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. Tests
/// pin: (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c>
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates
/// <c>FromUtc</c> to roughly an hour before "now" — proves the time-window
/// collapse without freezing the clock.
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
/// Channel is a single-select <c>&lt;select data-test="filter-channel-select"&gt;</c>;
/// Kind / Status / Site are
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls whose options are checkboxes tagged
/// <c>data-test="filter-&lt;dim&gt;-ms-opt-&lt;value&gt;"</c>. Tests pin:
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates <c>FromUtc</c>
/// to roughly an hour before "now" — proves the time-window collapse without
/// freezing the clock.
/// </summary>
public class AuditFilterBarTests : BunitContext
{
@@ -56,6 +61,7 @@ public class AuditFilterBarTests : BunitContext
"data-test=\"filter-script\"",
"data-test=\"filter-target\"",
"data-test=\"filter-actor\"",
"data-test=\"filter-execution-id\"",
"data-test=\"filter-errors-only\"",
};
foreach (var marker in markers)
@@ -71,38 +77,60 @@ public class AuditFilterBarTests : BunitContext
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Drive UI: toggle a Channel chip, type in the Target search box, click Apply.
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
// Drive UI: pick a Channel, type in the Target search box, click Apply.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel);
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
Assert.Equal("Plant-A-OPC", captured.Target);
}
[Fact]
public void ChangingChannel_ReplacesTheSelection_SingleSelect()
{
// Channel is single-select: picking a second channel replaces the first
// rather than adding to it (the page filters one channel at a time).
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
// Selecting "All channels" clears the channel filter entirely.
cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.Channels);
}
[Fact]
public void Channel_Narrows_Kind_Options_When_Selected()
{
var cut = Render<AuditFilterBar>();
// With no Channel selected, every kind chip is in the DOM.
// With no Channel selected, every kind option is in the DOM.
foreach (var kind in Enum.GetValues<AuditKind>())
{
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Select only ApiOutbound; Kind chips outside the channel-kind map drop out.
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
// Select ApiOutbound; Kind options outside the channel-kind map drop out.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
foreach (var kind in apiKinds)
{
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Sanity: an unrelated kind is gone.
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
}
[Fact]
@@ -117,14 +145,74 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
// Single-value filter contract: Failed leads the non-success set.
Assert.Equal(AuditStatus.Failed, captured!.Status);
// Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}.
Assert.NotNull(captured!.Statuses);
Assert.Equal(3, captured.Statuses!.Count);
Assert.Contains(AuditStatus.Failed, captured.Statuses);
Assert.Contains(AuditStatus.Parked, captured.Statuses);
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
// Now pin an explicit Status option — Errors-only must yield (explicit wins).
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(AuditStatus.Delivered, captured!.Status);
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
[Fact]
public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
{
// Task 9: multiple explicit Status chips all reach the filter — and they
// win over the Errors-only default.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.NotNull(captured!.Statuses);
Assert.Equal(2, captured.Statuses!.Count);
Assert.Contains(AuditStatus.Delivered, captured.Statuses);
Assert.Contains(AuditStatus.Failed, captured.Statuses);
}
[Fact]
public void Apply_WithPastedExecutionId_MapsThroughToFilter()
{
// The operator pastes a Guid into the Execution ID box; Apply must map it
// straight onto AuditLogQueryFilter.ExecutionId.
var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555");
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(executionId, captured!.ExecutionId);
}
[Fact]
public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
{
// Lax parsing: a blank box yields no constraint; garbage text likewise.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Null(captured!.ExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.ExecutionId);
}
[Fact]
@@ -22,7 +22,7 @@ public class AuditResultsGridTests : BunitContext
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a")
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null)
=> new()
{
EventId = Guid.NewGuid(),
@@ -33,6 +33,7 @@ public class AuditResultsGridTests : BunitContext
SourceSiteId = site,
Target = "demo-target",
Actor = "tester",
ExecutionId = executionId,
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
@@ -43,6 +44,12 @@ public class AuditResultsGridTests : BunitContext
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
// The grid's OnAfterRenderAsync calls into audit-grid.js (init + the
// sessionStorage load). Loose mode lets those unconfigured calls no-op
// — auditGrid.load returns null (no prior state) unless a test sets up
// an explicit JSInterop.Setup to return a stored payload.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private void StubPage(IReadOnlyList<AuditEvent> rows)
@@ -115,6 +122,49 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(target.EventId, captured!.EventId);
}
[Fact]
public void Render_IncludesExecutionIdColumn()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The ExecutionId column header is present alongside the spec columns.
Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
}
[Fact]
public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
{
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]");
// Short form: first 8 hex digits of the "N" form.
Assert.Equal("abcdef01", cell.TextContent.Trim());
// Monospace presentation; full value retained in the title attribute.
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
}
[Fact]
public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
{
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A null ExecutionId renders the em-dash placeholder, not a value cell.
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
}
[Fact]
public void Status_FailedRow_HasErrorBadgeClass()
{
@@ -131,4 +181,133 @@ public class AuditResultsGridTests : BunitContext
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
}
// --- column resize + reorder UX (#23 follow-ups Task 10) ---------------
//
// The drag interaction itself is browser-side (audit-grid.js) and covered
// by the Playwright suite. The bUnit tests below exercise the .NET-side
// load/apply/persist logic that the JS callbacks drive: graceful handling
// of stored orders, the reorder slot-move maths, and the resize minimum.
/// <summary>Column keys in default (spec) order — the fallback used everywhere.</summary>
private static readonly string[] DefaultOrder =
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
};
private static int HeaderIndex(string markup, string key)
=> markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal);
[Fact]
public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
foreach (var key in DefaultOrder)
{
// Each <th> carries the stable drag key and a resize handle.
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup);
}
}
[Fact]
public void ColumnOrderParameter_DrivesHeaderOrder()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.ColumnOrder, new[] { "Status", "Site" }));
// Status + Site move to the front; the omitted columns still render,
// appended in default order — Status precedes Site precedes Channel.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel"));
// No column is dropped — all ten headers are present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Drag Status onto OccurredAtUtc — Status should land in slot 0.
await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc"));
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc"));
// The new order was persisted to sessionStorage under the order key.
// Loose-mode JSInterop records every InvokeVoidAsync; find the save call.
var save = JSInterop.Invocations
.Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder");
Assert.Contains("Status", (string)save.Arguments[1]!);
}
[Fact]
public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A drag that would shrink the column to 10px must clamp to the 64px floor.
await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10));
// The clamped width is reflected as the --audit-col-width custom property.
Assert.Contains("--audit-col-width: 64px", cut.Markup);
// The width was persisted to sessionStorage under the widths key.
Assert.Contains(JSInterop.Invocations,
i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths");
}
[Fact]
public void StoredOrder_WithUnknownKey_DegradesGracefully()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
// A stale persisted order naming a removed column ("LegacyCol") plus a
// subset of real columns — the unknown key must be dropped and the
// omitted real columns appended in default order, never throwing.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult("[\"Status\",\"LegacyCol\",\"Site\"]");
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult((string?)null);
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Restored order applied: Status then Site at the front.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
// The unknown key produced no header and did not break rendering.
Assert.DoesNotContain("LegacyCol", cut.Markup);
// All ten real columns still present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public void StoredWidths_ForUnknownColumn_AreIgnored()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult((string?)null);
// A width for a real column and one for a removed column.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult("{\"Target\":220,\"LegacyCol\":300}");
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The valid column's width was applied; the stale one silently ignored.
Assert.Contains("--audit-col-width: 220px", cut.Markup);
Assert.DoesNotContain("300px", cut.Markup);
}
}
@@ -0,0 +1,177 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Components.Health;
using ScadaLink.Commons.Messages.Audit;
namespace ScadaLink.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="SiteCallKpiTiles"/> (Site Call Audit #22, Task 7).
/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked —
/// from a single <see cref="SiteCallKpiResponse"/> snapshot. The tests pin:
///
/// <list type="bullet">
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
/// <item>Tile values render the snapshot's counters.</item>
/// <item>Threshold borders fire correctly — danger on Parked &gt; 0, warning
/// on Stuck &gt; 0, none when those counts are zero, none on Buffered.</item>
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
/// <item>Tile clicks navigate to the correct pre-filtered Site Calls report URL.</item>
/// </list>
/// </summary>
public class SiteCallKpiTilesTests : BunitContext
{
private static SiteCallKpiResponse MakeSnapshot(int buffered, int parked, int stuck) =>
new(
CorrelationId: "k",
Success: true,
ErrorMessage: null,
BufferedCount: buffered,
ParkedCount: parked,
FailedLastInterval: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null,
StuckCount: stuck);
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 120, parked: 3, stuck: 7))
.Add(c => c.IsAvailable, true));
// Three stable data-test selectors — the contract for both these tests
// and any future Playwright sweep.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// Tile values render the snapshot's counters.
Assert.Contains(">120<", cut.Markup); // buffered
Assert.Contains(">7<", cut.Markup); // stuck
Assert.Contains(">3<", cut.Markup); // parked
}
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, (SiteCallKpiResponse?)null)
.Add(c => c.IsAvailable, false)
.Add(c => c.ErrorMessage, "site call repository unavailable"));
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
Assert.Contains("—", cut.Markup);
// Inline error message renders below.
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
}
[Fact]
public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void ParkedTile_NoDangerBorder_WhenParkedZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_GetsWarningBorder_WhenStuckAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
// Warning, not danger — Stuck is the softer signal.
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_NoWarningBorder_WhenStuckZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount()
{
// A non-zero buffer is normal operation — the Buffered tile is a plain
// count tile and never gets a danger/warning border.
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 5000, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
var cls = tile.GetAttribute("class") ?? string.Empty;
Assert.DoesNotContain("border-danger", cls);
Assert.DoesNotContain("border-warning", cls);
}
[Fact]
public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 50, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
tile.Click();
// Unfiltered /site-calls/report — no query string.
Assert.EndsWith("/site-calls/report", nav.Uri);
}
[Fact]
public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
tile.Click();
// Spec: Stuck tile drills into the report's "stuck only" filter.
Assert.Contains("/site-calls/report?stuck=true", nav.Uri);
}
[Fact]
public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
tile.Click();
// Spec: Parked tile drills into ?status=Parked.
Assert.Contains("/site-calls/report?status=Parked", nav.Uri);
}
}
@@ -36,10 +36,10 @@ public class AuditLogPageExportUrlTests
{
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var filter = new AuditLogQueryFilter(
Channel: AuditChannel.ApiOutbound,
Kind: AuditKind.ApiCall,
Status: AuditStatus.Failed,
SourceSiteId: "plant-a",
Channels: new[] { AuditChannel.ApiOutbound },
Kinds: new[] { AuditKind.ApiCall },
Statuses: new[] { AuditStatus.Failed },
SourceSiteIds: new[] { "plant-a" },
Target: "PaymentApi",
Actor: "apikey-1",
CorrelationId: corr,
@@ -65,7 +65,7 @@ public class AuditLogPageExportUrlTests
[Fact]
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
{
var filter = new AuditLogQueryFilter(Channel: AuditChannel.Notification);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.Notification });
var url = AuditLogPage.BuildExportUrl(filter);
@@ -74,4 +74,36 @@ public class AuditLogPageExportUrlTests
Assert.Single(query);
Assert.Equal("Notification", query["channel"]);
}
[Fact]
public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
{
var exec = Guid.Parse("12121212-3434-5656-7878-909090909090");
var filter = new AuditLogQueryFilter(ExecutionId: exec);
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Single(query);
Assert.Equal(exec.ToString(), query["executionId"]);
}
[Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{
// Task 9: each multi-value dimension emits one repeated query-string key
// per selected value so the export endpoint's ParseFilter sees them all.
var filter = new AuditLogQueryFilter(
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound },
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
SourceSiteIds: new[] { "plant-a", "plant-b" });
var url = AuditLogPage.BuildExportUrl(filter);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, query["channel"].ToArray());
Assert.Equal(new[] { "Failed", "Parked" }, query["status"].ToArray());
Assert.Equal(new[] { "plant-a", "plant-b" }, query["site"].ToArray());
}
}
@@ -46,6 +46,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPagePermissionTests : BunitContext
{
public AuditLogPagePermissionTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
// an init call). Loose mode lets those unconfigured JS calls no-op so
// the permission-gating tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
@@ -28,6 +28,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPageScaffoldTests : BunitContext
{
public AuditLogPageScaffoldTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
// an init call). Loose mode lets those unconfigured JS calls no-op so
// the page scaffold smoke tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
@@ -167,6 +176,44 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
[Fact]
public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
{
// The "View this execution" drill-in lands on /audit/log?executionId={id}.
// The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId
// set, and auto-loads the grid.
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == executionId),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin");
// An unparseable executionId leaves ExecutionId null. With no other filter
// params present the page renders but does NOT call the query service.
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
_queryService.DidNotReceive().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithTargetParam_AppliesTargetFilter()
{
@@ -197,7 +244,8 @@ public class AuditLogPageScaffoldTests : BunitContext
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.SourceSiteId == "plant-a"),
Arg.Is<AuditLogQueryFilter>(f =>
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
@@ -218,7 +266,8 @@ public class AuditLogPageScaffoldTests : BunitContext
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Status == AuditStatus.Failed),
Arg.Is<AuditLogQueryFilter>(f =>
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
@@ -9,6 +9,7 @@ using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types;
using ScadaLink.Communication;
@@ -37,6 +38,13 @@ public class HealthPageTests : BunitContext
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
// Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests
// that target the Site Call tiles override this before rendering.
private SiteCallKpiResponse _siteCallKpiReply =
new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1,
DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3),
StuckCount: 5);
public HealthPageTests()
{
_comms = new CommunicationService(
@@ -45,6 +53,9 @@ public class HealthPageTests : BunitContext
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
_comms.SetSiteCallAudit(siteCallAudit);
Services.AddSingleton(_comms);
var aggregator = Substitute.For<ICentralHealthAggregator>();
@@ -133,6 +144,53 @@ public class HealthPageTests : BunitContext
});
}
[Fact]
public void Renders_SiteCallKpiTiles_WithValues()
{
var cut = Render<HealthPage>();
// KPI data arrives via an async actor Ask after first render.
cut.WaitForAssertion(() =>
{
Assert.Contains("Site Calls", cut.Markup);
// The three Site Call tiles render at the documented data-test selectors.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// KPI numeric values surface in the tiles.
Assert.Contains(">9<", cut.Markup); // BufferedCount
Assert.Contains(">5<", cut.Markup); // StuckCount
Assert.Contains(">2<", cut.Markup); // ParkedCount
});
}
[Fact]
public void RendersLinkToTheSiteCallsReportPage()
{
var cut = Render<HealthPage>();
var link = cut.Find("a[href='/site-calls/report']");
Assert.Contains("View details", link.TextContent);
}
[Fact]
public void SiteCallKpiFailure_ShowsGracefulFallback()
{
_siteCallKpiReply = new SiteCallKpiResponse(
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// Failure must not crash the page; tiles fall back to a dash and the
// inline error message surfaces.
Assert.Contains("Site Calls", cut.Markup);
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
Assert.Contains(">—<", cut.Markup);
});
}
[Fact]
public void OutboxKpiFailure_ShowsGracefulFallback()
{
@@ -170,4 +228,16 @@ public class HealthPageTests : BunitContext
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
}
}
/// <summary>
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
/// the test's currently-scripted response.
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(HealthPageTests test)
{
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
}
}
}
@@ -1,7 +1,9 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -411,6 +413,77 @@ public class SiteCallsReportPageTests : BunitContext
});
}
// ─────────────────────────────────────────────────────────────────────────
// Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link
// here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The
// params must seed the filter BEFORE the first query so the initial grid load
// is already filtered, and the filter card controls must reflect the values.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
{
// The Parked KPI tile emits ?status=Parked — set the URI before render.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=Parked");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first (and only) query the page issues carries the Parked
// status filter — the grid load is pre-filtered, not unfiltered.
Assert.Single(_queryRequests);
Assert.Equal("Parked", _queryRequests[0].StatusFilter);
// The Status <select> control reflects the seeded value so the
// operator sees the filter and can Clear it.
var statusSelect = cut.Find("#sc-status");
Assert.Equal("Parked", statusSelect.GetAttribute("value"));
});
}
[Fact]
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
{
// The Stuck KPI tile emits ?stuck=true.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?stuck=true");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first query carries StuckOnly = true.
Assert.Single(_queryRequests);
Assert.True(_queryRequests[0].StuckOnly);
// The "Stuck only" checkbox is checked.
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.True(stuckCheckbox.HasAttribute("checked"));
});
}
[Fact]
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
{
// No drill-in params — the page loads exactly as before: an unfiltered
// query and no status/stuck filter set on the controls.
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(_queryRequests);
Assert.Null(_queryRequests[0].StatusFilter);
Assert.False(_queryRequests[0].StuckOnly);
var statusSelect = cut.Find("#sc-status");
Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.False(stuckCheckbox.HasAttribute("checked"));
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -34,7 +34,7 @@ public class AuditLogQueryServiceTests
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
{
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
var paging = new AuditLogPaging(PageSize: 25);
var expected = new List<AuditEvent>
{
@@ -179,7 +179,7 @@ public class AuditLogQueryServiceTests
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
// fresh DbContext, so this completes cleanly; with a shared scoped context
@@ -17,6 +17,7 @@ public class AuditEventTests
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
var corrId = Guid.NewGuid();
var execId = Guid.NewGuid();
var evt = new AuditEvent
{
@@ -26,6 +27,7 @@ public class AuditEventTests
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = corrId,
ExecutionId = execId,
SourceSiteId = "site-01",
SourceInstanceId = "inst-7",
SourceScript = "OnAlarm",
@@ -49,6 +51,7 @@ public class AuditEventTests
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(corrId, evt.CorrelationId);
Assert.Equal(execId, evt.ExecutionId);
Assert.Equal("site-01", evt.SourceSiteId);
Assert.Equal("inst-7", evt.SourceInstanceId);
Assert.Equal("OnAlarm", evt.SourceScript);
@@ -77,6 +80,7 @@ public class AuditEventTests
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
CorrelationId = null,
ExecutionId = null,
SourceSiteId = null,
SourceInstanceId = null,
SourceScript = null,
@@ -96,6 +100,7 @@ public class AuditEventTests
Assert.Null(evt.IngestedAtUtc);
Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript);
@@ -21,6 +21,21 @@ public class NotificationEntityTests
Assert.Equal("SiteA", n.SourceSiteId);
}
[Fact]
public void OriginExecutionId_DefaultsToNull_AndIsSettable()
{
// Audit Log #23: OriginExecutionId carries the originating script
// execution's id from the site so the dispatcher can echo it onto
// NotifyDeliver rows. Null for notifications submitted before the
// column existed; settable from the NotificationSubmit message.
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Null(n.OriginExecutionId);
var executionId = Guid.NewGuid();
n.OriginExecutionId = executionId;
Assert.Equal(executionId, n.OriginExecutionId);
}
[Fact]
public void Constructor_NullArguments_Throw()
{
@@ -40,6 +40,47 @@ public class NotificationMessagesTests
Assert.Null(msg.SourceScript);
}
[Fact]
public void NotificationSubmit_OriginExecutionId_DefaultsToNull()
{
// Audit Log #23: OriginExecutionId is an additive trailing member — a
// submit built without it (old call sites / old serialized payloads)
// leaves the id null.
var msg = new NotificationSubmit(
"notif-3", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
Assert.Null(msg.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied()
{
var executionId = Guid.NewGuid();
var msg = new NotificationSubmit(
"notif-4", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
Assert.Equal(executionId, msg.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip()
{
// The buffered S&F payload IS a serialized NotificationSubmit; the
// forwarder deserializes it, so OriginExecutionId must survive JSON.
var executionId = Guid.NewGuid();
var msg = new NotificationSubmit(
"notif-5", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
var json = System.Text.Json.JsonSerializer.Serialize(msg);
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
Assert.NotNull(roundTripped);
Assert.Equal(executionId, roundTripped!.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
{
@@ -0,0 +1,75 @@
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Commons.Tests.Types;
/// <summary>
/// Audit Log #23 (M8): tests for the shared lax multi-value query-param parsers
/// used by the ManagementService + CentralUI audit endpoints and the
/// <c>AuditLogPage</c> drill-in parser. The contract under test: parse each
/// repeated value independently, silently drop unparseable/blank elements, and
/// collapse an empty result to <c>null</c>.
/// </summary>
public class AuditQueryParamParsersTests
{
[Fact]
public void ParseEnumList_NullInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(null));
}
[Fact]
public void ParseEnumList_EmptyInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(Array.Empty<string?>()));
}
[Fact]
public void ParseEnumList_AllValuesValid_ParsesEverything()
{
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
new[] { "ApiOutbound", "DbOutbound" });
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
}
[Fact]
public void ParseEnumList_IsCaseInsensitive()
{
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(new[] { "apioutbound" });
Assert.Equal(new[] { AuditChannel.ApiOutbound }, result);
}
[Fact]
public void ParseEnumList_DropsUnparseableElement_KeepsTheRest()
{
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
new[] { "ApiOutbound", "NotAChannel", "Notification" });
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result);
}
[Fact]
public void ParseEnumList_AllValuesUnparseable_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditStatus>(new[] { "Bogus", "" }));
}
[Fact]
public void ParseStringList_NullInput_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseStringList(null));
}
[Fact]
public void ParseStringList_TrimsValuesAndDropsBlanks()
{
var result = AuditQueryParamParsers.ParseStringList(
new[] { " site-1 ", "", " ", "site-2", null });
Assert.Equal(new[] { "site-1", "site-2" }, result);
}
[Fact]
public void ParseStringList_AllBlank_ReturnsNull()
{
Assert.Null(AuditQueryParamParsers.ParseStringList(new[] { "", " ", null }));
}
}
@@ -19,6 +19,7 @@ public class AuditEventDtoMapperTests
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
var correlationId = Guid.NewGuid();
var executionId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var original = new AuditEvent
@@ -29,6 +30,7 @@ public class AuditEventDtoMapperTests
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached,
CorrelationId = correlationId,
ExecutionId = executionId,
SourceSiteId = "site-1",
SourceInstanceId = "Pump01",
SourceScript = "OnDemand",
@@ -54,6 +56,7 @@ public class AuditEventDtoMapperTests
Assert.Equal(original.Channel, roundTripped.Channel);
Assert.Equal(original.Kind, roundTripped.Kind);
Assert.Equal(original.CorrelationId, roundTripped.CorrelationId);
Assert.Equal(original.ExecutionId, roundTripped.ExecutionId);
Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId);
Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
Assert.Equal(original.SourceScript, roundTripped.SourceScript);
@@ -90,6 +93,7 @@ public class AuditEventDtoMapperTests
var dto = AuditEventDtoMapper.ToDto(evt);
Assert.Equal(string.Empty, dto.CorrelationId);
Assert.Equal(string.Empty, dto.ExecutionId);
Assert.Equal(string.Empty, dto.SourceSiteId);
Assert.Equal(string.Empty, dto.SourceInstanceId);
Assert.Equal(string.Empty, dto.SourceScript);
@@ -113,6 +117,7 @@ public class AuditEventDtoMapperTests
Kind = nameof(AuditKind.ApiCall),
Status = nameof(AuditStatus.Submitted),
CorrelationId = string.Empty,
ExecutionId = string.Empty,
SourceSiteId = string.Empty,
SourceInstanceId = string.Empty,
SourceScript = string.Empty,
@@ -128,6 +133,7 @@ public class AuditEventDtoMapperTests
var evt = AuditEventDtoMapper.FromDto(dto);
Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript);
@@ -74,8 +74,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.Where(p => !p.IsShadowProperty())
.ToList();
// AuditEvent record exposes 21 init-only properties (alog.md §4).
Assert.Equal(21, properties.Count);
// AuditEvent record exposes 22 init-only properties (alog.md §4 plus the
// additive ExecutionId universal correlation column).
Assert.Equal(22, properties.Count);
}
[Fact]
@@ -90,11 +91,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.ToList();
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
// index introduced alongside the composite PK (Bundle C).
// index introduced alongside the composite PK (Bundle C), plus the additive
// IX_AuditLog_Execution index supporting ExecutionId lookups.
var expected = new[]
{
"IX_AuditLog_Channel_Status_Occurred",
"IX_AuditLog_CorrelationId",
"IX_AuditLog_Execution",
"IX_AuditLog_OccurredAtUtc",
"IX_AuditLog_Site_Occurred",
"IX_AuditLog_Target_Occurred",
@@ -136,5 +139,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
var targetIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
var executionIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution");
Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter());
}
}
@@ -0,0 +1,70 @@
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Audit Log #23 (ExecutionId Task 5) integration test for the
/// <c>AddNotificationOriginExecutionId</c> migration: applies the EF migrations
/// to a freshly-created MSSQL test database on the running infra/mssql container
/// and asserts that the <c>Notifications</c> table carries the new
/// <c>OriginExecutionId</c> column as a nullable <c>uniqueidentifier</c>.
/// </summary>
/// <remarks>
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so
/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The
/// fixture applies the migrations once at construction time.
/// </remarks>
public class AddNotificationOriginExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddNotificationOriginExecutionIdMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_AddsOriginExecutionIdColumn_ToNotifications()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId' " +
"AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, present);
}
[SkippableFact]
public async Task OriginExecutionIdColumn_IsNullableUniqueIdentifier()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dataType = await ScalarAsync<string?>(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
Assert.Equal("uniqueidentifier", dataType);
var isNullable = await ScalarAsync<string?>(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
Assert.Equal("YES", isNullable);
}
// --- helpers ------------------------------------------------------------
private async Task<T> ScalarAsync<T>(string sql)
{
await using var conn = _fixture.OpenConnection();
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var result = await cmd.ExecuteScalarAsync();
if (result is null || result is DBNull)
{
return default!;
}
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
}
}
@@ -91,7 +91,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(3, rows.Count);
@@ -114,13 +114,116 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId),
new AuditLogQueryFilter(
Channels: new[] { AuditChannel.Notification },
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
}
[SkippableFact]
public async Task QueryAsync_FilterByMultipleChannels_ReturnsUnion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 14, 0, 0, DateTimeKind.Utc);
// One row per channel; the multi-value filter must return the union of
// ApiOutbound + Notification and exclude DbOutbound.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.DbOutbound));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.Notification },
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
Assert.DoesNotContain(rows, r => r.Channel == AuditChannel.DbOutbound);
}
[SkippableFact]
public async Task QueryAsync_FilterByMultipleStatuses_ReturnsUnion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 15, 0, 0, DateTimeKind.Utc);
// Failed + Parked are requested; Delivered must be excluded.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, status: AuditStatus.Failed));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), status: AuditStatus.Parked));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), status: AuditStatus.Delivered));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
Assert.DoesNotContain(rows, r => r.Status == AuditStatus.Delivered);
}
[SkippableFact]
public async Task QueryAsync_FilterByMultipleSourceSiteIds_ReturnsUnion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteA = NewSiteId();
var siteB = NewSiteId();
var siteC = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 16, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteA, occurredAtUtc: t0));
await repo.InsertIfNotExistsAsync(NewEvent(siteB, occurredAtUtc: t0.AddMinutes(1)));
await repo.InsertIfNotExistsAsync(NewEvent(siteC, occurredAtUtc: t0.AddMinutes(2)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteA, siteB }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.SourceSiteId, new[] { siteA, siteB }));
Assert.DoesNotContain(rows, r => r.SourceSiteId == siteC);
}
[SkippableFact]
public async Task QueryAsync_EmptyChannelList_DoesNotConstrain()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 17, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
// An empty Channels list must mean "no filter" — NOT WHERE 1=0.
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
Channels: Array.Empty<AuditChannel>(),
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
}
[SkippableFact]
public async Task QueryAsync_FilterBySourceSiteId()
{
@@ -137,13 +240,41 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
}
[SkippableFact]
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var executionId = Guid.NewGuid();
var t0 = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc);
// Two rows share the ExecutionId; one carries a different ExecutionId and
// one leaves it null — both must be excluded by the single-value filter.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: executionId));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: executionId));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: Guid.NewGuid()));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: null));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteIds: new[] { siteId },
ExecutionId: executionId),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId));
}
[SkippableFact]
public async Task QueryAsync_FilterByTimeRange()
{
@@ -160,7 +291,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteId: siteId,
SourceSiteIds: new[] { siteId },
FromUtc: t0.AddMinutes(10),
ToUtc: t0.AddHours(1)),
new AuditLogPaging(PageSize: 10));
@@ -187,7 +318,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
var page1 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count);
@@ -196,7 +327,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var cursor = page1[^1];
var page2 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor.OccurredAtUtc,
@@ -208,7 +339,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var cursor2 = page2[^1];
var page3 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
@@ -281,7 +412,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(e);
}
var filter = new AuditLogQueryFilter(SourceSiteId: siteId);
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { siteId });
var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count);
@@ -622,7 +753,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? errorMessage = null) =>
string? errorMessage = null,
Guid? executionId = null) =>
new()
{
EventId = Guid.NewGuid(),
@@ -632,5 +764,6 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Status = status,
SourceSiteId = siteId,
ErrorMessage = errorMessage,
ExecutionId = executionId,
};
}
@@ -268,6 +268,7 @@ public class NotificationOutboxConfigurationTests : IDisposable
var lastAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 1, 0, TimeSpan.Zero);
var nextAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 2, 0, TimeSpan.Zero);
var deliveredAt = new DateTimeOffset(2026, 5, 19, 8, 3, 0, TimeSpan.Zero);
var originExecutionId = Guid.NewGuid();
var notification = new Notification(id, NotificationType.Email, "Ops List",
"High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north")
@@ -279,6 +280,7 @@ public class NotificationOutboxConfigurationTests : IDisposable
ResolvedTargets = "ops@example.test;duty@example.test",
SourceInstanceId = "instance-42",
SourceScript = "TankLevelAlarm",
OriginExecutionId = originExecutionId,
SiteEnqueuedAt = siteEnqueuedAt,
CreatedAt = createdAt,
LastAttemptAt = lastAttemptAt,
@@ -311,6 +313,27 @@ public class NotificationOutboxConfigurationTests : IDisposable
Assert.Equal(lastAttemptAt, loaded.LastAttemptAt);
Assert.Equal(nextAttemptAt, loaded.NextAttemptAt);
Assert.Equal(deliveredAt, loaded.DeliveredAt);
Assert.Equal(originExecutionId, loaded.OriginExecutionId);
}
[Fact]
public async Task Notification_NullOriginExecutionId_RoundTripsAsNull()
{
// Audit Log #23: OriginExecutionId is an additive nullable column —
// notifications raised outside a script execution (or submitted before
// the column existed) persist and reload it as null.
var id = Guid.NewGuid().ToString();
var notification = new Notification(id, NotificationType.Email, "Ops List",
"Subject", "Body", "site-north");
_context.Notifications.Add(notification);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var loaded = await _context.Notifications.FindAsync(id);
Assert.NotNull(loaded);
Assert.Null(loaded!.OriginExecutionId);
}
[Fact]

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