Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5198b114b4 | |||
| fd76c19007 | |||
| 24cdfe373c | |||
| 1ba62052d6 | |||
| cfd8f1ecf4 | |||
| 6aac4c8ed7 | |||
| 85bb61a1f3 | |||
| 705ae95404 | |||
| 6f5a35f222 | |||
| 0149ce6180 | |||
| 6b16a48886 | |||
| 990731d12f | |||
| fd12021984 | |||
| 4002f4197b | |||
| 6ffa47f258 | |||
| c9229c35fc | |||
| aadb1fd72a | |||
| 8243f61e96 | |||
| 53508c79b2 | |||
| 849a011400 | |||
| 405de525ca | |||
| 77922abb33 | |||
| 5f544bfe1e | |||
| aaa6df24cf | |||
| ae7329034f | |||
| e36f0bf9c8 | |||
| a3eb659b75 | |||
| d34f536220 | |||
| 40955bbca6 | |||
| 7a386a80ce | |||
| c503df4c4c | |||
| f1478c5a19 | |||
| f64a7aed02 | |||
| 2a76be1f94 | |||
| 37c7a0e5ac | |||
| b3b02a8cb6 | |||
| 44f1ee372a | |||
| d73b459057 | |||
| 7e9d74697b | |||
| 3cf2b4d47e | |||
| 7816b840c1 | |||
| ac1f73cf8a | |||
| e3519fdb39 | |||
| 6f0d2ca499 | |||
| fdd1a4b886 | |||
| 6f59a1b546 | |||
| de5280d1c7 | |||
| 8c78913503 | |||
| 6d073046c6 | |||
| 5fe08eaceb | |||
| 44f7aabe31 | |||
| babf5b99e7 | |||
| 194cae2fbf |
@@ -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; M2–M8 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.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Audit Log ExecutionId — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review).
|
||||
|
||||
**Goal:** Add a dedicated `ExecutionId` column to the Audit Log — one universal correlation value, stamped on every audit row, identifying the originating script execution or inbound request.
|
||||
|
||||
**Architecture:** Additive nullable `ExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). Every emitter stamps it; the `ScriptRuntimeContext` per-execution id is the source for site script runs, threaded through the S&F buffer for retry-loop cached rows and through `NotificationSubmit` → `Notifications.OriginExecutionId` for central `NotifyDeliver` rows. `CorrelationId` is left as the per-operation lifecycle id (and reverts to `null` for sync one-shot calls). Validated design: `docs/plans/2026-05-21-audit-executionid-design.md`.
|
||||
|
||||
**Tech Stack:** .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright.
|
||||
|
||||
**Ground rules (every task):** branch is `feature/audit-executionid` (already created) — never commit to `main`. Edit in place; never touch `infra/*`; `docker/*` only if a task says so (none do). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. TDD; full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on). Additive contract evolution. Do not push.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Prep — verify branch + baseline
|
||||
|
||||
**Files:** none.
|
||||
|
||||
**Steps:** confirm `git branch --show-current` is `feature/audit-executionid`; `dotnet build ScadaLink.slnx` succeeds.
|
||||
|
||||
**Acceptance:** on the branch, solution builds clean.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Foundation — `AuditEvent.ExecutionId`, central `AuditLog` column, repository query
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ExecutionId`.
|
||||
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ExecutionId` filter dimension (single-value, like `CorrelationId`).
|
||||
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_Execution (ExecutionId)`.
|
||||
- Create: a new EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/` — `AddAuditLogExecutionId` — `ExecutionId uniqueidentifier NULL` + the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existing `AddNotificationsTable` migration style.
|
||||
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — `QueryAsync` translates `filter.ExecutionId` to `e.ExecutionId == value` (mirror the `CorrelationId` clause). Keyset paging untouched.
|
||||
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — `QueryAsync_FilterByExecutionId`; migration smoke if the suite has that pattern.
|
||||
|
||||
**Approach:** purely additive. `ExecutionId` is `Guid?` everywhere. Generate the migration with `dotnet ef migrations add` against the ConfigurationDatabase project (or hand-write mirroring an existing one — match how the repo does migrations).
|
||||
|
||||
**Commit:** `feat(auditlog): ExecutionId column on AuditEvent + central AuditLog`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Foundation — site SQLite + gRPC DTO
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table DDL; the insert command binds it; `MapRow` reads it back. (Site SQLite is created fresh by the writer — an additive column in the `CREATE TABLE` is enough; if the writer has any migration/ALTER path, extend it.)
|
||||
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
|
||||
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs` — `ToDto`/`FromDto` map `ExecutionId` ↔ `execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `CorrelationId` handling).
|
||||
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` (column present + round-trips); `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` (ExecutionId round-trip incl. null).
|
||||
|
||||
**Commit:** `feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Site script-side emitters stamp `ExecutionId`
|
||||
|
||||
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ExecutionId` = the context's per-execution id. Revert the interim "execution id in `CorrelationId` for sync rows" change so `CorrelationId` is purely per-operation again.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
|
||||
- Rename the field `_auditCorrelationId` → `_executionId` (and the ctor param `auditCorrelationId` → `executionId`) for clarity; update XML docs. Thread it to the helpers as today.
|
||||
- Sync `ApiCall` (`BuildCallAuditEvent`): set `ExecutionId = _executionId`; set `CorrelationId = null` (revert — sync one-shot calls have no operation lifecycle).
|
||||
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve`): set `ExecutionId = _executionId`; `CorrelationId` stays `trackedId.Value`.
|
||||
- `NotifySend` (`Notify.Send` emission): set `ExecutionId = _executionId`; `CorrelationId` stays the `NotificationId`.
|
||||
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_executionId` (rename from the audit-correlation param); sync `DbWrite` event sets `ExecutionId = _executionId` and `CorrelationId = null`. Cached DB write rows: `ExecutionId` set, `CorrelationId` stays `trackedId`.
|
||||
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, and `ExecutionCorrelationContextTests.cs` — assert `ExecutionId` is the context's id on every row; assert sync rows now have `CorrelationId == null`; assert cached/notification rows keep their `CorrelationId`.
|
||||
|
||||
**Commit:** `feat(auditlog): site script-side emitters stamp ExecutionId`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Cached S&F retry-loop rows carry `ExecutionId`
|
||||
|
||||
**What:** Thread the execution id through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry `ExecutionId`. This same threading fixes the pre-existing `SourceScript = null` gap on those rows (identical boundary).
|
||||
|
||||
**Files:**
|
||||
- Modify: the S&F buffered cached-call message / `StoreAndForwardMessage` (or the cached-call payload) in `src/ScadaLink.StoreAndForward/` — carry the originating execution id (and source script) alongside the call.
|
||||
- Modify: `CachedCallAttemptContext` (find it — `src/ScadaLink.AuditLog/Site/Telemetry/` or StoreAndForward) — add an `ExecutionId` (and `SourceScript`) field.
|
||||
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ExecutionId` from the context (and `SourceScript`, replacing the `SourceScript = null` line).
|
||||
- Modify the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext`) so the execution id is written into the buffered message.
|
||||
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ExecutionId`.
|
||||
|
||||
**Note for implementer:** this is the deepest task — the threading touches StoreAndForward. If the buffered message can't cleanly carry the id, STOP and report before guessing.
|
||||
|
||||
**Commit:** `feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Central `NotifyDeliver` rows carry `ExecutionId`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginExecutionId`.
|
||||
- Modify: `src/ScadaLink.Commons/Messages/Notification/` — `NotificationSubmit` carries `Guid? OriginExecutionId` (additive).
|
||||
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config + a new migration `AddNotificationOriginExecutionId` (`Notifications.OriginExecutionId uniqueidentifier NULL`).
|
||||
- Modify: the site `NotifySend` forward path — the execution id (already on the `NotifySend` audit row from Task 3) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder).
|
||||
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ExecutionId = notification.OriginExecutionId`.
|
||||
- Test: `tests/ScadaLink.NotificationOutbox.Tests/` — `NotifyDeliver` rows echo `OriginExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
|
||||
|
||||
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ExecutionId`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Inbound rows carry `ExecutionId`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs` — `EmitInboundAudit` sets `ExecutionId` to the request id (it already mints a `Guid.NewGuid()` for the inbound `CorrelationId` per the 2026-05-21 change; reuse that one id for `ExecutionId` — and reconsider whether the inbound row's `CorrelationId` should now be `null` to keep `CorrelationId` purely per-operation; align with the Task 3 decision: inbound is a one-shot from the audit row's perspective → `CorrelationId = null`, `ExecutionId = <request id>`).
|
||||
- Test: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — inbound row carries a non-null `ExecutionId`; distinct per request.
|
||||
|
||||
**Commit:** `feat(auditlog): inbound audit rows carry ExecutionId`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Central UI — ExecutionId column, filter, drill-in
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ExecutionId` to the column set (the grid already supports resize/reorder + a `ColumnOrder`); render it (short form / monospace).
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — an `ExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ExecutionId`.
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — `ApplyQueryStringFilters` accepts `?executionId=<guid>`; `BuildExportUrl` emits it.
|
||||
- Add a "View this execution" drill-in — a row/drilldown action linking `/audit/log?executionId=<guid>`. Mirror the existing `?correlationId=` drill-in.
|
||||
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in filters the grid).
|
||||
|
||||
Use the `frontend-design` skill for the column/filter styling.
|
||||
|
||||
**Commit:** `feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: CLI + ManagementService — ExecutionId filter
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs` — `audit query --execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `executionId`.
|
||||
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `executionId` query param into `AuditLogQueryFilter.ExecutionId` (lax-parse — unparseable dropped).
|
||||
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
|
||||
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
|
||||
|
||||
**Commit:** `feat(audit): ExecutionId filter in the CLI and ManagementService`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: End-to-end integration test + docs
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ExecutionIdCorrelationTests.cs` — boot a site+central pair; run a script that does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`; assert every resulting audit row (site + central) shares one `ExecutionId`.
|
||||
- Modify: `docs/requirements/Component-AuditLog.md` — add `ExecutionId` to the schema table and a sentence on its meaning vs `CorrelationId`. (Do NOT modify `alog.md` — it is the locked v1 spec.)
|
||||
- Modify: `CLAUDE.md` — one line under the Centralized Audit Log decisions noting `ExecutionId` as the universal per-run correlation value.
|
||||
|
||||
**Commit:** `test(auditlog): end-to-end ExecutionId correlation + docs`
|
||||
|
||||
---
|
||||
|
||||
## Final review
|
||||
|
||||
Dispatch a final cross-cutting review of the whole branch; full `dotnet build` + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
|
||||
|
||||
## Dependency summary
|
||||
|
||||
0 blocks all. 2 blockedBy 1. 3 blockedBy 2. 4 blockedBy 3. 5 blockedBy 2. 6 blockedBy 2. 7 blockedBy 1. 8 blockedBy 1. 9 blockedBy 3,4,5,6,7,8. Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → final review.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
|
||||
"tasks": [
|
||||
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
|
||||
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
|
||||
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
|
||||
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
|
||||
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
|
||||
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
|
||||
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
|
||||
],
|
||||
"lastUpdated": "2026-05-21T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
# Audit Log #23 — Deferred Follow-ups Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (bundled cadence — one implementer + one review pass per task).
|
||||
|
||||
**Goal:** Close the five deferred implementation follow-ups from the Audit Log #23 roadmap so site audit events actually reach central, the audit/SiteCall surfaces are complete, and known tech debt is paid down.
|
||||
|
||||
**Architecture:** Five independent-ish workstreams against the existing ScadaLink codebase. The headline change: site→central audit forwarding moves from the production `NoOpSiteStreamAuditClient` stub to a real **ClusterClient-based push** — the same transport notifications already use (`SiteCommunicationActor` → `ClusterClient.Send("/user/central-communication", …)` → `CentralCommunicationActor`), avoiding a new central-hosted gRPC server. The remaining four follow-ups are scoped tech-debt / UI / contract changes.
|
||||
|
||||
**Tech Stack:** .NET 10, Akka.NET (ClusterClient, ClusterClientReceptionist, cluster singletons, TestKit), EF Core 10 (MS SQL + SQLite providers), Blazor Server + Bootstrap CSS (no third-party UI libs), System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute, Playwright.
|
||||
|
||||
**Spec sources:** `alog.md`, `docs/requirements/Component-AuditLog.md`, `docs/requirements/Component-SiteCallAudit.md`, `docs/plans/2026-05-20-audit-log-code-roadmap.md` (header lines 14–19 enumerate the deferred items).
|
||||
|
||||
**Ground rules (carry into every task):**
|
||||
- Branch off `main` before any code change; never commit on `main`.
|
||||
- Edit in place. Never touch `infra/*`. The `docker/*` cluster config is touched only if a task explicitly says so (none here do).
|
||||
- Stage with explicit `git add <path>` — never `git add .`, never `git commit -am`.
|
||||
- TDD: failing test → minimal code → green → commit. Full solution stays green (`dotnet build ScadaLink.slnx`, `dotnet test ScadaLink.slnx`).
|
||||
- Additive message-contract evolution where possible; where a contract shape must change (Task 8), update every call site in the same task.
|
||||
- Do not push to origin — the user authorizes pushes separately.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Prep — feature branch
|
||||
|
||||
**Files:** none (git only).
|
||||
|
||||
**Step 1:** From a clean `main`, create the working branch:
|
||||
```bash
|
||||
git checkout main && git status --porcelain # expect clean
|
||||
git checkout -b feature/audit-log-followups
|
||||
```
|
||||
|
||||
**Step 2:** Confirm baseline green:
|
||||
```bash
|
||||
dotnet build ScadaLink.slnx
|
||||
```
|
||||
Expected: build succeeds. (A full `dotnet test` baseline is optional but recommended.)
|
||||
|
||||
**Acceptance:** on branch `feature/audit-log-followups`, solution builds.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Audit push — central ingest routing over ClusterClient
|
||||
|
||||
**What:** Make the receptionist-registered `CentralCommunicationActor` accept `IngestAuditEventsCommand` (and `IngestCachedTelemetryCommand`) from a site ClusterClient, forward to the `AuditLogIngestActor` cluster-singleton proxy, and pipe the reply back. Mirror the existing `NotificationSubmit` / `RegisterNotificationOutbox` pattern exactly.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs` — add `Receive<IngestAuditEventsCommand>` + `Receive<IngestCachedTelemetryCommand>` handlers; add a `RegisterAuditIngest` registration message handler holding the `AuditLogIngestActor` proxy `IActorRef` (mirror `RegisterNotificationOutbox` at line ~120 / `HandleNotificationSubmit` at line ~130).
|
||||
- Create: `src/ScadaLink.Commons/Messages/Audit/RegisterAuditIngest.cs` — `public sealed record RegisterAuditIngest(IActorRef AuditIngestActor);` (mirror `RegisterNotificationOutbox`).
|
||||
- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — after the central `AuditLogIngestActor` singleton + proxy are created (~lines 355–379), `Tell` the `RegisterAuditIngest` to the `CentralCommunicationActor` (mirror how the Notification Outbox proxy is registered).
|
||||
- Test: `tests/ScadaLink.Communication.Tests/Actors/CentralCommunicationActorAuditTests.cs` (new).
|
||||
|
||||
**Approach:**
|
||||
- Handler `Ask`s the registered audit-ingest proxy and `PipeTo`s the `IngestAuditEventsReply` back to the original `Sender` (the ClusterClient round-trips it to the site). Use the existing audit-ingest Ask-timeout convention (30s — see `SiteStreamGrpcServer` `AuditIngestAskTimeout`); add a bound option if no constant is reachable.
|
||||
- If no audit-ingest proxy is registered yet (startup race), reply with an empty `IngestAuditEventsReply([])` — the site keeps the rows `Pending` and retries, exactly as the gRPC handler does today.
|
||||
- `IngestCachedTelemetryCommand` is routed the same way (its reply type is the same `IngestAuditEventsReply` per `AuditLogIngestActor`).
|
||||
|
||||
**Tests (TestKit + NSubstitute):**
|
||||
1. `IngestAuditEventsCommand` with an audit-ingest probe registered → probe receives the command, actor replies the probe's `IngestAuditEventsReply` to the sender.
|
||||
2. `IngestAuditEventsCommand` with no audit-ingest registered → sender gets `IngestAuditEventsReply` with empty `AcceptedEventIds`.
|
||||
3. `IngestCachedTelemetryCommand` routes to the same proxy.
|
||||
|
||||
**Steps:** write failing tests → run (fail) → implement record + handlers + Host registration → run (pass) → `dotnet build ScadaLink.slnx` → commit.
|
||||
|
||||
**Commit:** `feat(communication): route audit ingest commands through CentralCommunicationActor`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Audit push — real site client, Host wiring, integration test
|
||||
|
||||
**What:** Replace `NoOpSiteStreamAuditClient` (production binding) with a real `ISiteStreamAuditClient` that pushes over ClusterClient via the site's `SiteCommunicationActor`. After this task the site `auditlog.db` `Pending` backlog drains to central.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs` — implements `ISiteStreamAuditClient`; ctor takes the `SiteCommunicationActor` `IActorRef` + an Ask timeout.
|
||||
- Modify: `src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs` — ensure `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` are forwarded over `ClusterClient.Send("/user/central-communication", …)` with the reply routed back to the Ask (mirror the `NotificationSubmit` forward at lines ~190/214/224).
|
||||
- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — in the site telemetry wiring (~lines 648–681), construct `ClusterClientSiteAuditClient` with the `SiteCommunicationActor` ref and pass it to `SiteAuditTelemetryActor` instead of the DI-resolved `NoOpSiteStreamAuditClient`.
|
||||
- Modify: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs` (line ~124–129) — keep `NoOpSiteStreamAuditClient` as the DI default (it remains correct for central/test composition roots that have no `SiteCommunicationActor`); update the stale comment that says "M6's reconciliation work brings the real implementation".
|
||||
- Test: `tests/ScadaLink.AuditLog.Tests/Site/Telemetry/ClusterClientSiteAuditClientTests.cs` (new); extend `tests/ScadaLink.IntegrationTests/AuditLog/` with a ClusterClient-push end-to-end test.
|
||||
|
||||
**Approach:**
|
||||
- `IngestAuditEventsAsync(AuditEventBatch, ct)` maps the batch to `IngestAuditEventsCommand(IReadOnlyList<AuditEvent>)`, `Ask`s the `SiteCommunicationActor` for `IngestAuditEventsReply`, maps the reply's `AcceptedEventIds` back into the `IngestAck` the `SiteAuditTelemetryActor` expects.
|
||||
- An Ask timeout / failure must **throw** — `SiteAuditTelemetryActor`'s drain loop already treats a thrown exception as transient (rows stay `Pending`, retried next tick). Keep that contract.
|
||||
- `IngestCachedTelemetryAsync` does the same with `IngestCachedTelemetryCommand`. (`CachedCallTelemetryForwarder` already resolves `ISiteStreamAuditClient` — no change there.)
|
||||
- `AuditEvent` already crosses the wire as the `NotificationSubmit` records do; confirm the Akka serializer handles `IReadOnlyList<AuditEvent>` (notification messages prove the pattern).
|
||||
|
||||
**Tests:**
|
||||
1. `IngestAuditEventsAsync` → batch becomes one `IngestAuditEventsCommand`; mocked actor reply's accepted ids map onto `IngestAck`.
|
||||
2. Partial ack (3 of 5 ids) → `IngestAck` lists only the 3.
|
||||
3. Ask timeout → method throws (drain loop keeps rows `Pending`).
|
||||
4. Integration: boot a site+central pair via the IntegrationTests harness, write an audit event on the site hot-path, assert a central `AuditLog` row appears within ~10s and the site row flips to `Forwarded`.
|
||||
|
||||
**Commit:** `feat(auditlog): real ClusterClient-based site audit push client`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Consolidate the duplicated audit DTO mappers
|
||||
|
||||
**What:** Collapse the 4 near-duplicate `AuditEvent`↔`AuditEventDto` mapping copies into one canonical mapper. The project-reference cycle (`AuditLog → Communication`, never the reverse) is resolved by hosting the canonical mapper **in `ScadaLink.Communication`** — it owns the generated `AuditEventDto` and references `Commons` for `AuditEvent`, and `AuditLog` already references `Communication`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs` — `public static class` with `ToDto(AuditEvent) → AuditEventDto` and `FromDto(AuditEventDto) → AuditEvent` (lift the canonical logic from `AuditLog/Telemetry/AuditEventMapper.cs`).
|
||||
- Modify: `src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs` — replace the inlined `IngestAuditEvents` loop (~lines 265–295), `AuditEventToDto` (~490–517) and `MapAuditEventFromDto` (~537–561) with calls to `AuditEventDtoMapper`.
|
||||
- Delete: `src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs`; update its callers in `ScadaLink.AuditLog` to use `Communication`'s `AuditEventDtoMapper`.
|
||||
- Leave untouched: `SqliteAuditWriter.MapRow` (SQLite `DataReader` → `AuditEvent`, not a DTO mapper — different source type) and `MapSiteCallFromDto` (SiteCall, not audit). Note this in the commit body.
|
||||
- Test: move/merge `tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs` into `tests/ScadaLink.Communication.Tests/Grpc/AuditEventDtoMapperTests.cs`; keep round-trip coverage (`FromDto(ToDto(x)) == x`).
|
||||
|
||||
**Approach:** Pure refactor — no behaviour change. Verify field-by-field parity against all 3 inlined copies before deleting them (null handling, enum parsing, `Int32Value`/`Timestamp` wrapping).
|
||||
|
||||
**Steps:** create mapper + tests → run → swap call sites → delete old copies → `dotnet build` + `dotnet test ScadaLink.slnx` (all green, no behaviour drift) → commit.
|
||||
|
||||
**Commit:** `refactor(auditlog): consolidate AuditEvent DTO mappers into Communication`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Site Call Audit — query / KPI / detail backend
|
||||
|
||||
**What:** Build the missing read-side backend for the Site Calls UI: Commons message contracts, `SiteCallAuditActor` query/KPI/detail handlers, and `CommunicationService` methods. Mirror `NotificationOutboxQueries.cs` + the Notification Outbox actor/service shape. Spec: `Component-SiteCallAudit.md` §KPIs and §queryable list.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs` — records mirroring `NotificationOutboxQueries.cs`:
|
||||
- `SiteCallQueryRequest` (CorrelationId, status/site/kind/target filters, date range, page cursor fields, PageSize)
|
||||
- `SiteCallSummary` (TrackedOperationId, SourceSite, Kind, TargetSummary, Status, RetryCount, LastError, provenance, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc)
|
||||
- `SiteCallQueryResponse` (CorrelationId, Success, ErrorMessage, IReadOnlyList<SiteCallSummary>, next-cursor fields)
|
||||
- `SiteCallKpiRequest` / `SiteCallKpiResponse` (BufferedCount, ParkedCount, FailedLastInterval, DeliveredLastInterval, OldestPendingAge, StuckCount — mirror the Notification Outbox KPI shape; also a per-site variant)
|
||||
- `SiteCallDetailRequest` / `SiteCallDetailResponse` / `SiteCallDetail` (full row incl. LastError, all timestamps).
|
||||
- Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` — add `ReceiveAsync` handlers for the query / KPI / detail requests; query handler calls `ISiteCallAuditRepository.QueryAsync` (keyset paging on `(CreatedAtUtc DESC, TrackedOperationId DESC)`); KPI handler computes point-in-time counts from the `SiteCalls` table (stuck = `Pending`/`Retrying` older than the configurable threshold, default 10 min). Use the per-message DI scope pattern already in the actor.
|
||||
- Add repo support if needed: `src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs` may need a KPI-count method + a detail `GetAsync` (a `GetAsync(TrackedOperationId)` already exists).
|
||||
- Modify: `src/ScadaLink.Communication/CommunicationService.cs` — add `QuerySiteCallsAsync`, `GetSiteCallKpisAsync`, `GetPerSiteSiteCallKpisAsync`, `GetSiteCallDetailAsync` (mirror `QueryNotificationOutboxAsync` etc.: `Ask` the `SiteCallAuditActor` proxy with `_options.QueryTimeout`).
|
||||
- Test: `tests/ScadaLink.SiteCallAudit.Tests/` (actor handlers), `tests/ScadaLink.Commons.Tests/` (contract shape), `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs` (extend for KPI counts).
|
||||
|
||||
**Commit:** `feat(sitecallaudit): query, KPI and detail backend for the Site Calls page`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Site Call Audit — Retry/Discard relay to owning site
|
||||
|
||||
**What:** Central UI Retry/Discard on a parked Site Call must relay `RetryParkedOperation` / `DiscardParkedOperation` to the **owning site** (sites are the source of truth — central never mutates the `SiteCalls` row directly; the corrected row arrives back via telemetry). Spec: `Component-SiteCallAudit.md` §actions-on-parked-rows.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.Commons/Messages/Audit/SiteCallRelayMessages.cs` — `RetryParkedOperationRequest`/`Response`, `DiscardParkedOperationRequest`/`Response` (carry `TrackedOperationId`, `SourceSite`, `CorrelationId`; response carries Success + a "site unreachable" error case).
|
||||
- Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` (or a small relay collaborator) — on a relay request, look up the owning site and forward `RetryParkedOperation`/`DiscardParkedOperation` to that site over the central→site ClusterClient (the central side already maintains one ClusterClient per site; reuse the `CentralCommunicationActor` site-addressing path). On no/late reply → respond "site unreachable".
|
||||
- Modify: `src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs` — receive `RetryParkedOperation`/`DiscardParkedOperation` and hand to the site operation-tracking subsystem.
|
||||
- Modify the site operation-tracking owner (S&F operation-tracking store / `ParkedMessageHandlerActor` in `src/ScadaLink.StoreAndForward/`) — Retry resets a parked tracked operation to `Pending` for the retry loop; Discard marks it `Discarded`. Reuse the parked-message handling that already backs notification Retry/Discard.
|
||||
- Modify: `src/ScadaLink.Communication/CommunicationService.cs` — add `RetrySiteCallAsync` / `DiscardSiteCallAsync`.
|
||||
- Test: `tests/ScadaLink.SiteCallAudit.Tests/` (relay routing + unreachable path), `tests/ScadaLink.StoreAndForward.Tests/` (site-side parked op reset/discard), `tests/ScadaLink.Communication.Tests/`.
|
||||
|
||||
**Note for implementer:** this is the meatiest backend task — the central→site relay direction and the site-side parked-operation mutation are both required. If the site operation-tracking Retry/Discard primitive already exists for cached calls, reuse it; only add the message plumbing.
|
||||
|
||||
**Commit:** `feat(sitecallaudit): central→site Retry/Discard relay for parked operations`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Site Calls UI page + nav + Audit drill-in
|
||||
|
||||
**What:** Build the Central UI Site Calls page — a near-mirror of `NotificationReport.razor`. Spec: `Component-SiteCallAudit.md`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor` (+ `.razor.cs`) — route `@page "/site-calls/report"`, `RequireDeployment` (or `OperationalAudit`) auth to match the Notifications report gating. Structure (per the form-layout memory: header, filter card, results table, paging, modal):
|
||||
- Filter card: Status, Kind, Source site, Target keyword, date range, "Stuck only" checkbox, Clear/Query.
|
||||
- Results table columns: TrackedOperationId, Source site, Kind, Target, Status (badge + Stuck indicator), Retries, Last error, Created, Updated, Actions.
|
||||
- Actions column: a **"View audit history"** link `href="/audit/log?correlationId=@row.TrackedOperationId"` (the `TrackedOperationId` is the audit `CorrelationId`) — mirror `NotificationReport.razor:172`; plus **Retry/Discard** buttons shown only on `Parked` rows (none on `Failed`).
|
||||
- Keyset Previous/Next paging; double-click row → detail modal (body shows full row + LastError; reuse the Notifications detail-modal idiom — never `MarkupString`).
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor` — register the Site Calls page (own "Site Calls" section, or under an existing group, consistent with the `Notifications` / `Audit` section pattern at lines ~65–129).
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — confirm `?correlationId=` drill-in already covers this (it does); no change expected — just verify.
|
||||
- Test: `tests/ScadaLink.CentralUI.Tests/Pages/` (bUnit — scaffold, paging, parked-only actions, drill-in link), `tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs` (new).
|
||||
|
||||
**Use the `frontend-design` skill** for page/component styling guidance. Blazor Server + Bootstrap only; custom components; clean corporate aesthetic.
|
||||
|
||||
**Commit:** `feat(centralui): Site Calls page with Retry/Discard and Audit drill-in`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Site Call KPI tiles + Health dashboard integration
|
||||
|
||||
**What:** Surface Site Call Audit KPIs on the Health dashboard, mirroring the Notification Outbox tiles + `AuditKpiTiles`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor` (+ `.razor.cs`) — mirror `Components/Health/AuditKpiTiles.razor`; tiles for Buffered, Parked (danger border if >0), Stuck (warning border if >0); each tile navigates to `/site-calls/report` with a query-string filter.
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor` (+ code-behind) — add a "Site Calls" KPI section between the Notification Outbox and Audit Log sections; load via `CommunicationService.GetSiteCallKpisAsync` (Task 4).
|
||||
- Test: `tests/ScadaLink.CentralUI.Tests/` (bUnit — tile rendering, threshold borders, navigation targets).
|
||||
|
||||
**Commit:** `feat(centralui): Site Call KPI tiles on the Health dashboard`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Multi-value `AuditLogQueryFilter` — contract + repository
|
||||
|
||||
**What:** Widen `AuditLogQueryFilter` from single-value to multi-value on the `Channel`, `Kind`, `Status`, `SourceSiteId` dimensions, and translate them to `IN (...)` in the repository. `Target`, `Actor`, `CorrelationId`, `FromUtc`, `ToUtc` stay as-is. Keyset paging must not change.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — change `Channel`/`Kind`/`Status`/`SourceSiteId` to `IReadOnlyList<…>?` (e.g. `IReadOnlyList<AuditChannel>? Channels`). Keep the record's other params. This is a **breaking shape change** — update every call site in this task.
|
||||
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` (`QueryAsync`, ~lines 119–165) — each widened dimension becomes `if (filter.Channels is { Count: > 0 }) query = query.Where(e => filter.Channels.Contains(e.Channel));`. Empty/null list = no filter. Keyset predicate + `OrderByDescending` untouched.
|
||||
- Update all other `AuditLogQueryFilter` constructors in this task so the solution compiles (ManagementService `ParseFilter`, CentralUI `AuditQueryModel.ToFilter`, CLI helpers, tests) — the deep behaviour of those consumers is Task 9; here just make them compile (e.g. wrap a single value in a one-element list).
|
||||
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — add `QueryAsync_FilterByMultipleChannels_ReturnsUnion`, multi-status, multi-site; keep the existing single-value and keyset tests green.
|
||||
|
||||
**Commit:** `feat(auditlog): multi-value AuditLogQueryFilter dimensions`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Multi-value filters — ManagementService, CLI, Central UI
|
||||
|
||||
**What:** Make the three consumers actually emit/accept multiple values per dimension instead of collapsing to the first.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` (`ParseFilter`, ~lines 369–414) — read repeated query params with `.ToArray()` (not `.ToString()`); parse each into the enum list; unparseable values silently dropped (keep the existing lax contract).
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs` (`ToFilter`, ~lines 110–126) — stop collapsing to `.First()`; pass the full `Channels`/`Kinds`/`Statuses`/`SiteIdentifiers` sets. Adjust the `ErrorsOnly` logic (lines ~128–145) for multi-value `Status`. The chip UI already supports multi-select — no `.razor` change expected; verify.
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` export-URL builder (~lines 175–227) — emit repeated query-string params per selected value.
|
||||
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` (~lines 29–41) — make `--channel`/`--kind`/`--status`/`--site` accept multiple values (System.CommandLine multi-arity options; keep `AcceptOnlyFromAmong` for the enum-like ones). Modify `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs` — `AuditQueryArgs` fields become arrays; `BuildQueryString` emits one key per value.
|
||||
- Test: extend `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`, `tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs`, `tests/ScadaLink.CentralUI.Tests/` filter-model tests for multi-value round-trips.
|
||||
|
||||
**Commit:** `feat(audit): multi-value filters across ManagementService, CLI and Central UI`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Audit results grid — column resize + reorder UX
|
||||
|
||||
**What:** Add drag-to-resize and drag-to-reorder column UX to `AuditResultsGrid`, persisted in `sessionStorage`. Blazor + Bootstrap + minimal JS interop only (no third-party libs).
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js` — a `window.auditGrid` namespace: column-resize drag handlers, header drag-reorder handlers, and `save(key,json)` / `load(key)` over `sessionStorage` (mirror `treeview-storage.js`).
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — render a resize handle in each `<th>`; make headers draggable; apply persisted widths (inline style/CSS var) and column order (the `ColumnOrder` parameter + `OrderedColumns()` already exist — wire it to persisted state); `IJSRuntime` calls to load on first render and save on change.
|
||||
- Create: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css` — resize-handle styling, drag-over feedback (mirror `AuditDrilldownDrawer.razor.css` / `TreeView.razor.css` idioms).
|
||||
- Reference the script from the host page (`App.razor` / `_Host` / layout — match where `monaco-init.js` / `session-expiry.js` are referenced).
|
||||
- Test: extend `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs` (or new `AuditGridColumnTests.cs`) — resize changes a column width, reorder changes header order, both survive a reload via `sessionStorage`.
|
||||
|
||||
**Use the `frontend-design` skill** for the resize-handle / drag-feedback visual treatment.
|
||||
|
||||
**Commit:** `feat(centralui): column resize and reorder for the audit results grid`
|
||||
|
||||
---
|
||||
|
||||
## Final review
|
||||
|
||||
After Task 10: dispatch a final cross-cutting code review of the whole branch against this plan, then run the full solution build + test once more. Update `docs/plans/2026-05-20-audit-log-code-roadmap.md` header lines 14–19 to strike the five now-completed follow-ups (leaving the three v1.x items). Hand back to the user for the push decision (do not push).
|
||||
|
||||
---
|
||||
|
||||
## Task dependency summary
|
||||
|
||||
- Task 0 blocks everything.
|
||||
- Task 2 blocked by Task 1.
|
||||
- Task 3 independent (after Task 0).
|
||||
- Task 5 blocked by Task 4.
|
||||
- Task 6 blocked by Tasks 4 and 5.
|
||||
- Task 7 blocked by Task 4.
|
||||
- Task 9 blocked by Task 8.
|
||||
- Task 10 independent (after Task 0).
|
||||
|
||||
Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → final review.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-21-audit-log-followups.md",
|
||||
"tasks": [
|
||||
{"id": 33, "subject": "Task 0: Prep — feature branch", "status": "completed"},
|
||||
{"id": 34, "subject": "Task 1: Audit push — central ingest routing over ClusterClient", "status": "completed", "blockedBy": [33]},
|
||||
{"id": 35, "subject": "Task 2: Audit push — real site client, Host wiring, integration test", "status": "completed", "blockedBy": [34]},
|
||||
{"id": 36, "subject": "Task 3: Consolidate the duplicated audit DTO mappers", "status": "completed", "blockedBy": [33]},
|
||||
{"id": 37, "subject": "Task 4: Site Call Audit — query / KPI / detail backend", "status": "completed", "blockedBy": [33]},
|
||||
{"id": 38, "subject": "Task 5: Site Call Audit — Retry/Discard relay to owning site", "status": "completed", "blockedBy": [37]},
|
||||
{"id": 39, "subject": "Task 6: Site Calls UI page + nav + Audit drill-in", "status": "completed", "blockedBy": [37, 38]},
|
||||
{"id": 40, "subject": "Task 7: Site Call KPI tiles + Health dashboard integration", "status": "completed", "blockedBy": [37]},
|
||||
{"id": 41, "subject": "Task 8: Multi-value AuditLogQueryFilter — contract + repository", "status": "completed", "blockedBy": [33]},
|
||||
{"id": 42, "subject": "Task 9: Multi-value filters — ManagementService, CLI, Central UI", "status": "completed", "blockedBy": [41]},
|
||||
{"id": 43, "subject": "Task 10: Audit results grid — column resize + reorder UX", "status": "completed", "blockedBy": [33]}
|
||||
],
|
||||
"lastUpdated": "2026-05-21T12:00:00Z"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -121,11 +121,14 @@ public static class ServiceCollectionExtensions
|
||||
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
|
||||
filter: sp.GetRequiredService<IAuditPayloadFilter>()));
|
||||
|
||||
// ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings
|
||||
// the real gRPC-backed implementation (no site→central gRPC channel
|
||||
// exists today — sites talk to central via Akka ClusterClient only).
|
||||
// Bundle H's integration test substitutes a stub directly into the
|
||||
// SiteAuditTelemetryActor's Props.Create call.
|
||||
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
|
||||
// central/test composition roots that have no SiteCommunicationActor.
|
||||
// The real implementation is ClusterClientSiteAuditClient, which pushes
|
||||
// audit telemetry to central over Akka ClusterClient via the site's
|
||||
// SiteCommunicationActor — the Host wires it directly into the
|
||||
// SiteAuditTelemetryActor's Props.Create call for site roles (it cannot
|
||||
// be a DI singleton because it needs the SiteCommunicationActor IActorRef,
|
||||
// created during Akka bootstrap, not at DI-composition time).
|
||||
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
|
||||
|
||||
// M3 Bundle F: site-side dual emitter for cached-call lifecycle
|
||||
|
||||
@@ -114,12 +114,53 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
PayloadTruncated INTEGER NOT NULL,
|
||||
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
|
||||
@@ -351,6 +397,55 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns up to <paramref name="limit"/> rows in
|
||||
/// <see cref="AuditForwardState.Forwarded"/>, oldest
|
||||
/// <see cref="AuditEvent.OccurredAtUtc"/> first, with
|
||||
/// <see cref="AuditEvent.EventId"/> as the deterministic tiebreaker. The
|
||||
/// <see cref="AuditForwardState.Forwarded"/>-specific counterpart of
|
||||
/// <see cref="ReadPendingAsync"/>; used by tests to assert a row reached the
|
||||
/// <see cref="AuditForwardState.Forwarded"/> state specifically (unlike
|
||||
/// <see cref="ReadPendingSinceAsync"/>, which also returns
|
||||
/// <see cref="AuditForwardState.Pending"/> rows).
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
|
||||
}
|
||||
|
||||
// Mirror ReadPendingAsync: the write lock guards the single connection.
|
||||
lock (_writeLock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||
ExecutionId
|
||||
FROM AuditLog
|
||||
WHERE ForwardState = $forwarded
|
||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add(MapRow(reader));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flips the supplied EventIds from <see cref="AuditForwardState.Pending"/> to
|
||||
/// <see cref="AuditForwardState.Forwarded"/> in a single UPDATE. Non-existent
|
||||
@@ -417,7 +512,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||
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
|
||||
@@ -594,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
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using Akka.Actor;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
|
||||
namespace ScadaLink.AuditLog.Site.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="ISiteStreamAuditClient"/> binding for site composition
|
||||
/// roots: pushes audit telemetry to central over Akka <c>ClusterClient</c> via
|
||||
/// the site's <c>SiteCommunicationActor</c>. The actor forwards the command to
|
||||
/// <c>/user/central-communication</c> and the central
|
||||
/// <c>CentralCommunicationActor</c> Asks the <c>AuditLogIngestActor</c> proxy —
|
||||
/// the same command/control transport notifications already use. Wired by the
|
||||
/// Host for site roles; central and test composition roots keep the
|
||||
/// <see cref="NoOpSiteStreamAuditClient"/> DI default (they have no
|
||||
/// <c>SiteCommunicationActor</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Throw-on-failure contract.</b> An Ask timeout or a faulted reply
|
||||
/// (<see cref="Status.Failure"/>) propagates as a thrown exception out of the
|
||||
/// <c>Ingest*Async</c> methods — it is NOT caught and turned into an empty ack.
|
||||
/// The <see cref="SiteAuditTelemetryActor"/> drain loop treats a thrown
|
||||
/// exception as transient and leaves the rows <c>Pending</c> for the next tick.
|
||||
/// Swallowing the fault into an empty ack would be indistinguishable from "zero
|
||||
/// rows accepted" and would silently lose the retry signal. Task 1 confirmed
|
||||
/// the central receiving end does not collapse an ingest fault into an empty
|
||||
/// ack either, so a site-side Ask through the whole path faults cleanly on a
|
||||
/// central-side timeout.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The batches arrive as proto DTOs (<see cref="AuditEventBatch"/> /
|
||||
/// <see cref="CachedTelemetryBatch"/>) because the
|
||||
/// <see cref="SiteAuditTelemetryActor"/> builds them with
|
||||
/// <see cref="AuditEventDtoMapper.ToDto"/>. This client converts them back into
|
||||
/// the <see cref="AuditEvent"/> / <see cref="SiteCall"/> entities the Akka
|
||||
/// command messages carry — the same DTO→entity translation the
|
||||
/// <c>SiteStreamGrpcServer</c> performs for the gRPC reconciliation path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClusterClientSiteAuditClient : ISiteStreamAuditClient
|
||||
{
|
||||
private readonly IActorRef _siteCommunicationActor;
|
||||
private readonly TimeSpan _askTimeout;
|
||||
|
||||
/// <param name="siteCommunicationActor">
|
||||
/// The site's <c>SiteCommunicationActor</c> — it forwards the ingest command
|
||||
/// over the registered central ClusterClient and routes the reply back to
|
||||
/// this client's Ask.
|
||||
/// </param>
|
||||
/// <param name="askTimeout">
|
||||
/// Ask timeout for the round-trip to central. On expiry the Ask throws
|
||||
/// <see cref="Akka.Actor.AskTimeoutException"/>, which the drain loop treats
|
||||
/// as transient (rows stay <c>Pending</c>).
|
||||
/// </param>
|
||||
public ClusterClientSiteAuditClient(IActorRef siteCommunicationActor, TimeSpan askTimeout)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(siteCommunicationActor);
|
||||
_siteCommunicationActor = siteCommunicationActor;
|
||||
_askTimeout = askTimeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(batch);
|
||||
|
||||
var events = new List<AuditEvent>(batch.Events.Count);
|
||||
foreach (var dto in batch.Events)
|
||||
{
|
||||
events.Add(AuditEventDtoMapper.FromDto(dto));
|
||||
}
|
||||
|
||||
// Ask<T> throws AskTimeoutException on timeout and rethrows a
|
||||
// Status.Failure's inner cause — both surface as a thrown exception so
|
||||
// the drain loop keeps the rows Pending. We deliberately do NOT catch.
|
||||
var reply = await _siteCommunicationActor
|
||||
.Ask<IngestAuditEventsReply>(new IngestAuditEventsCommand(events), _askTimeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return ToAck(reply.AcceptedEventIds);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(batch);
|
||||
|
||||
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
|
||||
foreach (var packet in batch.Packets)
|
||||
{
|
||||
var audit = AuditEventDtoMapper.FromDto(packet.AuditEvent);
|
||||
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
|
||||
entries.Add(new CachedTelemetryEntry(audit, siteCall));
|
||||
}
|
||||
|
||||
// Same throw-on-failure contract as IngestAuditEventsAsync. The reply
|
||||
// type is IngestCachedTelemetryReply (the central dual-write reply),
|
||||
// distinct from IngestAuditEventsReply.
|
||||
var reply = await _siteCommunicationActor
|
||||
.Ask<IngestCachedTelemetryReply>(new IngestCachedTelemetryCommand(entries), _askTimeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return ToAck(reply.AcceptedEventIds);
|
||||
}
|
||||
|
||||
private static IngestAck ToAck(IReadOnlyList<Guid> acceptedEventIds)
|
||||
{
|
||||
var ack = new IngestAck();
|
||||
foreach (var id in acceptedEventIds)
|
||||
{
|
||||
ack.AcceptedEventIds.Add(id.ToString());
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
}
|
||||
@@ -3,40 +3,40 @@ using ScadaLink.Communication.Grpc;
|
||||
namespace ScadaLink.AuditLog.Site.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Mockable abstraction over the central site-stream gRPC client surface that
|
||||
/// <see cref="SiteAuditTelemetryActor"/> uses to push <see cref="AuditEventBatch"/>
|
||||
/// payloads. The production implementation (added in Bundle E host wiring)
|
||||
/// wraps the auto-generated <c>SiteStreamService.SiteStreamServiceClient</c>;
|
||||
/// unit tests substitute via NSubstitute against this interface so the actor
|
||||
/// never needs a live gRPC channel.
|
||||
/// Mockable abstraction over the central site-audit push surface that
|
||||
/// <see cref="SiteAuditTelemetryActor"/> uses to forward <see cref="AuditEventBatch"/>
|
||||
/// payloads. The production implementation is
|
||||
/// <see cref="ClusterClientSiteAuditClient"/> — a ClusterClient-based client,
|
||||
/// wired in the Host for site roles, that forwards batches to central via the
|
||||
/// site's <c>SiteCommunicationActor</c>. Unit tests substitute via NSubstitute
|
||||
/// against this interface so the actor never needs a live transport.
|
||||
/// </summary>
|
||||
public interface ISiteStreamAuditClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes <paramref name="batch"/> to the central <c>IngestAuditEvents</c>
|
||||
/// RPC. The returned <see cref="IngestAck"/> carries the
|
||||
/// <c>accepted_event_ids</c> the actor will flip to
|
||||
/// Forwards <paramref name="batch"/> to the central audit-ingest path. The
|
||||
/// returned <see cref="IngestAck"/> carries the <c>accepted_event_ids</c>
|
||||
/// the actor will flip to
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
|
||||
/// in the site SQLite queue.
|
||||
/// </summary>
|
||||
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23 / M3)
|
||||
/// to the central <c>IngestCachedTelemetry</c> RPC. Each packet carries both
|
||||
/// the audit row and the operational <c>SiteCalls</c> upsert; central writes
|
||||
/// both in a single MS SQL transaction. Returns the same
|
||||
/// <see cref="IngestAck"/> shape as <see cref="IngestAuditEventsAsync"/> so
|
||||
/// the M3 site-side forwarder can flip the underlying audit rows to
|
||||
/// Forwards the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23)
|
||||
/// to the central cached-telemetry ingest path. Each packet carries both the
|
||||
/// audit row and the operational <c>SiteCalls</c> upsert; central writes both
|
||||
/// in a single MS SQL transaction. Returns the same <see cref="IngestAck"/>
|
||||
/// shape as <see cref="IngestAuditEventsAsync"/> so the site-side forwarder
|
||||
/// can flip the underlying audit rows to
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
|
||||
/// once central has acknowledged them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The production gRPC-backed implementation lands in M6 (no site→central
|
||||
/// gRPC channel exists today); until then the default
|
||||
/// <see cref="NoOpSiteStreamAuditClient"/> binding returns an empty ack and
|
||||
/// integration tests substitute a direct-actor client that routes the batch
|
||||
/// straight into the in-process <c>AuditLogIngestActor</c>.
|
||||
/// The production <see cref="ClusterClientSiteAuditClient"/> forwards over
|
||||
/// the ClusterClient transport; the <see cref="NoOpSiteStreamAuditClient"/>
|
||||
/// DI default (used by central and test composition roots) returns an empty
|
||||
/// ack so no rows are flipped.
|
||||
/// </remarks>
|
||||
Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -5,20 +5,18 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
|
||||
/// <summary>
|
||||
/// Default <see cref="ISiteStreamAuditClient"/> registered by
|
||||
/// <see cref="ScadaLink.AuditLog.ServiceCollectionExtensions.AddAuditLog"/>.
|
||||
/// Ships with M2 site-sync-pipeline wiring; the real gRPC-backed
|
||||
/// implementation is deferred to M6 reconciliation, where a site→central gRPC
|
||||
/// channel will be introduced (no such channel exists today — sites talk to
|
||||
/// central exclusively via Akka ClusterClient, while the gRPC SiteStreamService
|
||||
/// is hosted on the SITE side for central→site streaming).
|
||||
/// It is a no-op binding for composition roots that have no
|
||||
/// <c>SiteCommunicationActor</c> — central and test roots. Site roles override
|
||||
/// it in the Host with the ClusterClient-based
|
||||
/// <see cref="ClusterClientSiteAuditClient"/>, which actually forwards audit
|
||||
/// telemetry to central.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Returns an empty <see cref="IngestAck"/> so the
|
||||
/// <see cref="SiteAuditTelemetryActor"/> doesn't flip any rows to
|
||||
/// <c>Forwarded</c> when this NoOp is in effect — Bundle H's integration test
|
||||
/// substitutes a stub client that routes directly to the central
|
||||
/// <c>AuditLogIngestActor</c> in-process. Production wiring (M6) will replace
|
||||
/// this binding with a real client.
|
||||
/// <c>Forwarded</c> when this NoOp is in effect — rows stay <c>Pending</c>
|
||||
/// until a real client (or a test stub) takes over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Audit-write paths are best-effort by contract: a NoOp client keeps the
|
||||
@@ -35,7 +33,8 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(batch);
|
||||
// Empty ack — no EventIds will be flipped to Forwarded, so rows stay
|
||||
// Pending until M6's real client (or a Bundle H test stub) takes over.
|
||||
// Pending until the real ClusterClientSiteAuditClient (or a test stub)
|
||||
// takes over.
|
||||
return Task.FromResult(EmptyAck);
|
||||
}
|
||||
|
||||
@@ -43,11 +42,10 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
public Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(batch);
|
||||
// Empty ack — same rationale as IngestAuditEventsAsync. The M3
|
||||
// CachedCallTelemetryForwarder still writes the audit + tracking rows to
|
||||
// the site SQLite stores authoritatively; central-side state only
|
||||
// materialises once M6's real gRPC client (or a Bundle G test stub) is
|
||||
// wired in.
|
||||
// Empty ack — same rationale as IngestAuditEventsAsync. The site still
|
||||
// writes the audit + tracking rows to its SQLite stores authoritatively;
|
||||
// central-side state only materialises once the real
|
||||
// ClusterClientSiteAuditClient (or a test stub) is wired in.
|
||||
return Task.FromResult(EmptyAck);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Telemetry;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
@@ -136,7 +135,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
||||
var batch = new AuditEventBatch();
|
||||
foreach (var e in events)
|
||||
{
|
||||
batch.Events.Add(AuditEventMapper.ToDto(e));
|
||||
batch.Events.Add(AuditEventDtoMapper.ToDto(e));
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
@@ -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&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);
|
||||
|
||||
|
||||
@@ -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&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)
|
||||
|
||||
@@ -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 →</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 > 0</c>; the Stuck
|
||||
/// tile gets a warning border when <c>StuckCount > 0</c>. Buffered is a plain
|
||||
/// count tile with no threshold colour — a non-zero buffer is normal operation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class SiteCallKpiTiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
|
||||
/// or the load failed — the tiles render em dashes in that case.
|
||||
/// </summary>
|
||||
[Parameter] public SiteCallKpiResponse? Snapshot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="Snapshot"/> is a successful query result. False when
|
||||
/// the parent's refresh threw, or the response itself reported a fault, and
|
||||
/// the displayed values should be rendered as em dashes with an error
|
||||
/// explanation underneath.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional error message to render underneath the tiles when
|
||||
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
|
||||
/// section on the Health dashboard surfaces transient KPI failures.
|
||||
/// </summary>
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
|
||||
// ── Buffered tile ───────────────────────────────────────────────────────
|
||||
|
||||
private string BufferedDisplay =>
|
||||
IsAvailable && Snapshot is not null
|
||||
? Snapshot.BufferedCount.ToString("N0")
|
||||
: "—";
|
||||
|
||||
private void NavigateToBuffered()
|
||||
{
|
||||
// Buffered is "everything still in flight" — no single status maps to
|
||||
// it, so the natural drill-in is the unfiltered Site Calls report sorted
|
||||
// by newest, mirroring how the Audit volume/backlog tiles drop the
|
||||
// operator on the unfiltered Audit Log grid.
|
||||
Navigation.NavigateTo("/site-calls/report");
|
||||
}
|
||||
|
||||
// ── Stuck tile ──────────────────────────────────────────────────────────
|
||||
|
||||
private string StuckDisplay =>
|
||||
IsAvailable && Snapshot is not null
|
||||
? Snapshot.StuckCount.ToString("N0")
|
||||
: "—";
|
||||
|
||||
// Stuck above zero is a warning signal — cached calls that have been
|
||||
// Pending/Retrying past the stuck-age threshold. Matches the Notification
|
||||
// Outbox Stuck tile (border-warning when StuckCount > 0).
|
||||
private string StuckBorderClass =>
|
||||
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
|
||||
? "border-warning"
|
||||
: string.Empty;
|
||||
|
||||
private string StuckTextClass =>
|
||||
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
|
||||
? "text-warning"
|
||||
: string.Empty;
|
||||
|
||||
private void NavigateToStuck()
|
||||
{
|
||||
// Drill in with the report's "stuck only" filter pre-applied.
|
||||
Navigation.NavigateTo("/site-calls/report?stuck=true");
|
||||
}
|
||||
|
||||
// ── Parked tile ─────────────────────────────────────────────────────────
|
||||
|
||||
private string ParkedDisplay =>
|
||||
IsAvailable && Snapshot is not null
|
||||
? Snapshot.ParkedCount.ToString("N0")
|
||||
: "—";
|
||||
|
||||
// Parked above zero is a danger signal — cached calls that exhausted retries
|
||||
// and need an operator Retry/Discard. Matches the Notification Outbox Parked
|
||||
// tile (border-danger when ParkedCount > 0).
|
||||
private string ParkedBorderClass =>
|
||||
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
|
||||
? "border-danger"
|
||||
: string.Empty;
|
||||
|
||||
private string ParkedTextClass =>
|
||||
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
|
||||
? "text-danger"
|
||||
: string.Empty;
|
||||
|
||||
private void NavigateToParked()
|
||||
{
|
||||
// Drill in pre-filtered to Parked — the report's Status filter accepts
|
||||
// ?status=Parked and Parked rows carry the Retry/Discard relay actions.
|
||||
Navigation.NavigateTo("/site-calls/report?status=Parked");
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,19 @@
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Site Calls — Site Call Audit (#22). Deployment-role only,
|
||||
matching the Notification Report page's gate; the section
|
||||
header sits inside the policy block so a non-Deployment
|
||||
user does not see the heading. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="siteCallsContext">
|
||||
<div role="presentation" class="nav-section-header">Site Calls</div>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
||||
Parked Messages are Deployment-role only (Component-CentralUI). *@
|
||||
<div role="presentation" class="nav-section-header">Monitoring</div>
|
||||
|
||||
@@ -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<string?></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) =>
|
||||
|
||||
@@ -268,6 +268,64 @@
|
||||
<dd class="col-sm-9 text-danger">@d.LastError</dd>
|
||||
}
|
||||
</dl>
|
||||
|
||||
@* ── Recipients ── *@
|
||||
<hr />
|
||||
<h6 class="mb-2">Recipients</h6>
|
||||
@if (_detailLoading)
|
||||
{
|
||||
<div class="text-muted small">
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
Loading details…
|
||||
</div>
|
||||
}
|
||||
else if (_detailError != null)
|
||||
{
|
||||
<div class="text-danger small">@_detailError</div>
|
||||
}
|
||||
else if (_detail != null)
|
||||
{
|
||||
var recipients = ParseRecipients(_detail.ResolvedTargets);
|
||||
if (recipients.Count > 0)
|
||||
{
|
||||
<ul class="mb-0">
|
||||
@foreach (var recipient in recipients)
|
||||
{
|
||||
<li>@recipient</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted small">
|
||||
Not yet resolved — recipients are resolved from list
|
||||
"@d.ListName" at delivery time.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@* ── Body ── *@
|
||||
<hr />
|
||||
<h6 class="mb-2">Message body</h6>
|
||||
@if (_detailLoading)
|
||||
{
|
||||
<div class="text-muted small">
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
Loading details…
|
||||
</div>
|
||||
}
|
||||
else if (_detailError != null)
|
||||
{
|
||||
<div class="text-danger small">@_detailError</div>
|
||||
}
|
||||
else if (_detail != null)
|
||||
{
|
||||
@* Email bodies are plain text (design: BCC delivery, plain text).
|
||||
Rendered as preformatted text — never as a MarkupString, which
|
||||
would be an XSS vector. *@
|
||||
<pre class="border rounded bg-light p-2 mb-0"
|
||||
style="max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word;">@_detail.Body</pre>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (d.Status == "Parked")
|
||||
@@ -304,6 +362,9 @@
|
||||
|
||||
// Row detail modal
|
||||
private NotificationSummary? _detailNotification;
|
||||
private NotificationDetail? _detail;
|
||||
private bool _detailLoading;
|
||||
private string? _detailError;
|
||||
|
||||
// Filters
|
||||
private string _statusFilter = string.Empty;
|
||||
@@ -440,9 +501,80 @@
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private void ShowDetail(NotificationSummary n) => _detailNotification = n;
|
||||
private async Task ShowDetail(NotificationSummary n)
|
||||
{
|
||||
// The summary fields render immediately; Body + recipients fill in once the
|
||||
// full-detail fetch completes.
|
||||
_detailNotification = n;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
private void CloseDetail() => _detailNotification = null;
|
||||
try
|
||||
{
|
||||
var response = await CommunicationService.GetNotificationDetailAsync(
|
||||
new NotificationDetailRequest(Guid.NewGuid().ToString("N"), n.NotificationId));
|
||||
if (response.Success && response.Detail != null)
|
||||
{
|
||||
_detail = response.Detail;
|
||||
}
|
||||
else
|
||||
{
|
||||
_detailError = response.ErrorMessage ?? "Failed to load notification detail.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_detailError = $"Failed to load notification detail: {ex.Message}";
|
||||
}
|
||||
_detailLoading = false;
|
||||
}
|
||||
|
||||
private void CloseDetail()
|
||||
{
|
||||
_detailNotification = null;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort parse of <c>ResolvedTargets</c> into individual recipient addresses.
|
||||
/// The field may be a JSON string array, or a comma/semicolon-separated string.
|
||||
/// Returns an empty list when null/empty.
|
||||
/// </summary>
|
||||
private static List<string> ParseRecipients(string? resolvedTargets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resolvedTargets))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var trimmed = resolvedTargets.Trim();
|
||||
if (trimmed.StartsWith('['))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = System.Text.Json.JsonSerializer.Deserialize<List<string>>(trimmed);
|
||||
if (parsed != null)
|
||||
{
|
||||
return parsed
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r))
|
||||
.Select(r => r.Trim())
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Not valid JSON — fall through to the delimiter-split path.
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task RetryFromDetail(NotificationSummary n)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
@page "/site-calls/report"
|
||||
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.Commons.Messages.Audit
|
||||
@using ScadaLink.Communication
|
||||
@inject CommunicationService CommunicationService
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject IDialogService Dialog
|
||||
@inject ILogger<SiteCallsReport> Logger
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Site Calls</h4>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
|
||||
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ── Filters ── *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="sc-status">Status</label>
|
||||
<select id="sc-status" class="form-select form-select-sm" style="min-width: 130px;"
|
||||
@bind="_statusFilter">
|
||||
<option value="">All</option>
|
||||
<option value="Submitted">Submitted</option>
|
||||
<option value="Forwarded">Forwarded</option>
|
||||
<option value="Attempted">Attempted</option>
|
||||
<option value="Delivered">Delivered</option>
|
||||
<option value="Parked">Parked</option>
|
||||
<option value="Failed">Failed</option>
|
||||
<option value="Discarded">Discarded</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="sc-channel">Channel</label>
|
||||
<select id="sc-channel" class="form-select form-select-sm" style="min-width: 130px;"
|
||||
@bind="_channelFilter">
|
||||
<option value="">All</option>
|
||||
<option value="ApiOutbound">ApiOutbound</option>
|
||||
<option value="DbOutbound">DbOutbound</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="sc-site">Source site</label>
|
||||
<select id="sc-site" class="form-select form-select-sm" style="min-width: 150px;"
|
||||
@bind="_siteFilter">
|
||||
<option value="">Any</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.SiteIdentifier">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="sc-from">From</label>
|
||||
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
|
||||
@bind="_fromFilter" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label small mb-1" for="sc-to">To</label>
|
||||
<input id="sc-to" type="datetime-local" class="form-control form-control-sm"
|
||||
@bind="_toFilter" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label small mb-1" for="sc-search">Target keyword</label>
|
||||
<input id="sc-search" type="search" class="form-control form-control-sm"
|
||||
placeholder="Exact target…" @bind="_targetFilter" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input" type="checkbox" id="sc-stuck-only"
|
||||
@bind="_stuckOnly" />
|
||||
<label class="form-check-label small" for="sc-stuck-only">Stuck only</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
|
||||
disabled="@(!HasActiveFilters)">Clear</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_loading"
|
||||
data-test="site-calls-query">
|
||||
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||
Query
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_listError != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_listError</div>
|
||||
}
|
||||
|
||||
@* ── Site call list ── *@
|
||||
@if (_siteCalls == null)
|
||||
{
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="text-muted small">Loading…</div>
|
||||
}
|
||||
}
|
||||
else if (_siteCalls.Count == 0)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body text-center text-muted py-5">
|
||||
<div class="fs-5 mb-1">No site calls</div>
|
||||
<div class="small">No cached calls match the current filters.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Tracked operation</th>
|
||||
<th>Source site</th>
|
||||
<th>Channel</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Retries</th>
|
||||
<th>Last error</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in _siteCalls)
|
||||
{
|
||||
<tr @key="c.TrackedOperationId" class="@(c.IsStuck ? "table-warning" : "")"
|
||||
style="cursor: pointer;" @ondblclick="() => ShowDetail(c)"
|
||||
title="Double-click for full detail">
|
||||
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
|
||||
<td><span class="small">@SiteName(c.SourceSite)</span></td>
|
||||
<td>@c.Channel</td>
|
||||
<td>@c.Target</td>
|
||||
<td>
|
||||
<span class="badge @StatusBadgeClass(c.Status)">@c.Status</span>
|
||||
@if (c.IsStuck)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Stuck</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end font-monospace">@c.RetryCount</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(c.LastError))
|
||||
{
|
||||
<div class="small text-danger text-truncate" style="max-width: 280px;"
|
||||
title="@c.LastError">@c.LastError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td><TimestampDisplay Value="@AsOffset(c.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
|
||||
<td><TimestampDisplay Value="@AsOffset(c.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
|
||||
<td class="text-end" @ondblclick:stopPropagation="true">
|
||||
@* The TrackedOperationId is the audit CorrelationId, so the
|
||||
link deep-links into the central Audit Log pre-filtered to
|
||||
this cached call's lifecycle events. *@
|
||||
<a class="btn btn-outline-secondary btn-sm me-1"
|
||||
href="/audit/log?correlationId=@c.TrackedOperationId"
|
||||
data-test="audit-link-@c.TrackedOperationId">
|
||||
View audit history
|
||||
</a>
|
||||
@* Retry/Discard relay only on Parked rows — central relays the
|
||||
action to the owning site; Failed and other statuses are not
|
||||
actionable from central. *@
|
||||
@if (c.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm me-1"
|
||||
@onclick="() => RetrySiteCall(c)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => DiscardSiteCall(c)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@* Keyset paging — the Task 4 query response carries a (CreatedAtUtc, Id)
|
||||
cursor rather than page numbers, so we keep a stack of cursors to step
|
||||
backwards and the response's NextAfter* cursor to step forwards. *@
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small">
|
||||
@* No "of N" total: keyset paging has no cheap total-count, so
|
||||
the label is intentionally page-number-only. Do not "fix"
|
||||
this by adding a total — that would require a COUNT(*). *@
|
||||
Page @(_cursorStack.Count + 1) · @_siteCalls.Count rows
|
||||
</span>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary btn-sm me-1"
|
||||
@onclick="PrevPage" disabled="@(_cursorStack.Count == 0 || _loading)"
|
||||
data-test="site-calls-prev">Previous</button>
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
@onclick="NextPage" disabled="@(!HasNextPage || _loading)"
|
||||
data-test="site-calls-next">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Row detail modal ── *@
|
||||
@if (_detailSiteCall != null)
|
||||
{
|
||||
var d = _detailSiteCall;
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"
|
||||
@onclick="CloseDetail">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Site Call Detail — @ShortId(d.TrackedOperationId)</h6>
|
||||
<button type="button" class="btn-close" aria-label="Close"
|
||||
@onclick="CloseDetail"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_detailLoading)
|
||||
{
|
||||
<div class="text-muted small">
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
Loading details…
|
||||
</div>
|
||||
}
|
||||
else if (_detailError != null)
|
||||
{
|
||||
<div class="text-danger small">@_detailError</div>
|
||||
}
|
||||
else if (_detail != null)
|
||||
{
|
||||
var det = _detail;
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Tracked operation</dt>
|
||||
<dd class="col-sm-9"><code>@det.TrackedOperationId</code></dd>
|
||||
|
||||
<dt class="col-sm-3">Source site</dt>
|
||||
<dd class="col-sm-9">@SiteName(det.SourceSite)</dd>
|
||||
|
||||
<dt class="col-sm-3">Channel</dt>
|
||||
<dd class="col-sm-9">@det.Channel</dd>
|
||||
|
||||
<dt class="col-sm-3">Target</dt>
|
||||
<dd class="col-sm-9">@det.Target</dd>
|
||||
|
||||
<dt class="col-sm-3">Status</dt>
|
||||
<dd class="col-sm-9">
|
||||
<span class="badge @StatusBadgeClass(det.Status)">@det.Status</span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Retry count</dt>
|
||||
<dd class="col-sm-9 font-monospace">@det.RetryCount</dd>
|
||||
|
||||
<dt class="col-sm-3">HTTP status</dt>
|
||||
<dd class="col-sm-9">@(det.HttpStatus?.ToString() ?? "—")</dd>
|
||||
|
||||
<dt class="col-sm-3">Created</dt>
|
||||
<dd class="col-sm-9">
|
||||
<TimestampDisplay Value="@AsOffset(det.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Updated</dt>
|
||||
<dd class="col-sm-9">
|
||||
<TimestampDisplay Value="@AsOffset(det.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Terminal</dt>
|
||||
<dd class="col-sm-9">
|
||||
<TimestampDisplay Value="@AsOffset(det.TerminalAtUtc)"
|
||||
Format="yyyy-MM-dd HH:mm:ss" NullText="—" />
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Ingested (central)</dt>
|
||||
<dd class="col-sm-9">
|
||||
<TimestampDisplay Value="@AsOffset(det.IngestedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
|
||||
</dd>
|
||||
|
||||
@if (!string.IsNullOrEmpty(det.LastError))
|
||||
{
|
||||
<dt class="col-sm-3">Last error</dt>
|
||||
@* Plain text — never a MarkupString. *@
|
||||
<dd class="col-sm-9 text-danger">@det.LastError</dd>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (d.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm"
|
||||
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Pages.SiteCalls;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the central Site Calls report page (Site Call Audit #22). A
|
||||
/// near-mirror of <see cref="ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport"/>:
|
||||
/// it queries the central <c>SiteCalls</c> table via
|
||||
/// <see cref="ScadaLink.Communication.CommunicationService.QuerySiteCallsAsync"/>,
|
||||
/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard
|
||||
/// of <c>Parked</c> cached calls to their owning site.
|
||||
///
|
||||
/// <para>
|
||||
/// Unlike the Notification report, the query response uses a <c>(CreatedAtUtc DESC,
|
||||
/// TrackedOperationId DESC)</c> keyset cursor rather than page numbers, so paging
|
||||
/// keeps a stack of the cursors that opened each page (to step backwards) plus the
|
||||
/// response's <c>NextAfter*</c> cursor (to step forwards).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retry/Discard relay to the owning site has a distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/>
|
||||
/// outcome — central is an eventually-consistent mirror, not the source of truth, so
|
||||
/// a relay that never reaches the site is a transient transport condition, surfaced
|
||||
/// to the operator differently from a generic failure.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
|
||||
/// with <c>?status=Parked</c> (Parked tile) or <c>?stuck=true</c> (Stuck tile). On
|
||||
/// initialization those params seed <see cref="_statusFilter"/> / <see cref="_stuckOnly"/>
|
||||
/// BEFORE the first <see cref="RefreshAll"/>, so the first grid load is already
|
||||
/// filtered and the filter card controls reflect the seeded values. Parsing is lax
|
||||
/// — an absent, blank, or unrecognised value is silently dropped and the page loads
|
||||
/// unfiltered, mirroring <c>AuditLogPage</c>'s drill-in convention.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class SiteCallsReport
|
||||
{
|
||||
private const int PageSize = 50;
|
||||
|
||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||
|
||||
// The Status filter <select> options — the exact strings the dropdown binds and
|
||||
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
|
||||
// filter when it matches one of these (case-insensitively); anything else is
|
||||
// dropped so a hand-crafted bad URL still renders the page unfiltered.
|
||||
private static readonly string[] ValidStatuses =
|
||||
{
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
|
||||
};
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private List<Site> _sites = new();
|
||||
|
||||
// List
|
||||
private List<SiteCallSummary>? _siteCalls;
|
||||
private bool _loading;
|
||||
private string? _listError;
|
||||
private bool _actionInProgress;
|
||||
|
||||
// Keyset paging. The first page is opened with the empty (null, null) cursor.
|
||||
// _cursorStack holds the cursors of the PREVIOUSLY visited pages — it is empty
|
||||
// on page 1, has one entry on page 2, and so on; Previous pops it. _nextCursor
|
||||
// is the cursor for the following page, echoed back by the last query.
|
||||
private readonly Stack<(DateTime? AfterCreatedAtUtc, Guid? AfterId)> _cursorStack = new();
|
||||
private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null);
|
||||
private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor;
|
||||
|
||||
// Row detail modal
|
||||
private SiteCallSummary? _detailSiteCall;
|
||||
private SiteCallDetail? _detail;
|
||||
private bool _detailLoading;
|
||||
private string? _detailError;
|
||||
|
||||
// Filters
|
||||
private string _statusFilter = string.Empty;
|
||||
private string _channelFilter = string.Empty;
|
||||
private string _siteFilter = string.Empty;
|
||||
private string _targetFilter = string.Empty;
|
||||
private bool _stuckOnly;
|
||||
private DateTime? _fromFilter;
|
||||
private DateTime? _toFilter;
|
||||
|
||||
private bool HasNextPage => _nextCursor is not null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal — the source-site filter just falls back to raw site IDs.
|
||||
Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
|
||||
}
|
||||
|
||||
// Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
|
||||
// grid load is already filtered (and the filter card controls reflect it).
|
||||
ApplyQueryStringFilters();
|
||||
|
||||
await RefreshAll();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
|
||||
/// string. <c>?status=<status></c> seeds <see cref="_statusFilter"/> when it
|
||||
/// matches a known status (case-insensitive); <c>?stuck=true</c> seeds
|
||||
/// <see cref="_stuckOnly"/>. Lax parsing — an absent, blank, or unrecognised value
|
||||
/// is silently dropped, leaving the filter empty (the no-param behaviour).
|
||||
/// </summary>
|
||||
private void ApplyQueryStringFilters()
|
||||
{
|
||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||
|
||||
if (query.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.TryGetValue("status", out var statusValues))
|
||||
{
|
||||
var v = statusValues.ToString();
|
||||
// Round-trip the dropdown's own option strings (the KPI tile emits the
|
||||
// canonical casing, e.g. ?status=Parked); normalise to that casing so the
|
||||
// <select> binds. An unrecognised value leaves the filter unset.
|
||||
var match = ValidStatuses.FirstOrDefault(
|
||||
s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (match is not null)
|
||||
{
|
||||
_statusFilter = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (query.TryGetValue("stuck", out var stuckValues)
|
||||
&& bool.TryParse(stuckValues.ToString(), out var stuck))
|
||||
{
|
||||
_stuckOnly = stuck;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
|
||||
private async Task RefreshAll()
|
||||
{
|
||||
await FetchPage(_currentCursor);
|
||||
}
|
||||
|
||||
/// <summary>Apply the filters and start again from the first page.</summary>
|
||||
private async Task Search()
|
||||
{
|
||||
_cursorStack.Clear();
|
||||
await FetchPage((null, null));
|
||||
}
|
||||
|
||||
private async Task PrevPage()
|
||||
{
|
||||
if (_cursorStack.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The top of the stack is the cursor of the page BEFORE the current one.
|
||||
var previousCursor = _cursorStack.Pop();
|
||||
await FetchPage(previousCursor);
|
||||
}
|
||||
|
||||
private async Task NextPage()
|
||||
{
|
||||
if (_nextCursor is not { } next)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Stepping forward: remember the current page's cursor so Previous can
|
||||
// return to it.
|
||||
_cursorStack.Push(_currentCursor);
|
||||
await FetchPage(next);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch one keyset page starting after <paramref name="cursor"/>.
|
||||
/// </summary>
|
||||
private async Task FetchPage(
|
||||
(DateTime? AfterCreatedAtUtc, Guid? AfterId) cursor)
|
||||
{
|
||||
_loading = true;
|
||||
_listError = null;
|
||||
try
|
||||
{
|
||||
var request = new SiteCallQueryRequest(
|
||||
CorrelationId: Guid.NewGuid().ToString("N"),
|
||||
StatusFilter: NullIfEmpty(_statusFilter),
|
||||
SourceSiteFilter: NullIfEmpty(_siteFilter),
|
||||
ChannelFilter: NullIfEmpty(_channelFilter),
|
||||
TargetKeyword: NullIfEmpty(_targetFilter),
|
||||
StuckOnly: _stuckOnly,
|
||||
FromUtc: ToUtc(_fromFilter),
|
||||
ToUtc: ToUtc(_toFilter),
|
||||
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
|
||||
AfterId: cursor.AfterId,
|
||||
PageSize: PageSize);
|
||||
|
||||
var response = await CommunicationService.QuerySiteCallsAsync(request);
|
||||
if (response.Success)
|
||||
{
|
||||
_siteCalls = response.SiteCalls.ToList();
|
||||
_currentCursor = cursor;
|
||||
|
||||
// The response echoes the last row's cursor. A short page (fewer
|
||||
// rows than requested) has no further page even if a cursor came
|
||||
// back, so gate Next on a full page too.
|
||||
_nextCursor = response.NextAfterCreatedAtUtc is { } nextCreated
|
||||
&& response.NextAfterId is { } nextId
|
||||
&& _siteCalls.Count == PageSize
|
||||
? (nextCreated, nextId)
|
||||
: null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_listError = response.ErrorMessage ?? "Query failed.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_listError = $"Query failed: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task RetrySiteCall(SiteCallSummary c)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Retry cached call",
|
||||
$"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||
$"to site {SiteName(c.SourceSite)}?");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var response = await CommunicationService.RetrySiteCallAsync(
|
||||
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
|
||||
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
|
||||
appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
|
||||
if (response.Success)
|
||||
{
|
||||
await RefreshAll();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Retry failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task DiscardSiteCall(SiteCallSummary c)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard cached call",
|
||||
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var response = await CommunicationService.DiscardSiteCallAsync(
|
||||
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
|
||||
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
|
||||
appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
|
||||
if (response.Success)
|
||||
{
|
||||
await RefreshAll();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Discard failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Surface a relay outcome on the toast — exactly one toast per relay
|
||||
/// response. The <see cref="SiteCallRelayOutcome.SiteUnreachable"/> case is
|
||||
/// deliberately distinct from a generic failure: the action was not applied
|
||||
/// but the operator can retry once the site is back online.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="SiteCallRelayOutcome"/> switch is exhaustive, so it owns
|
||||
/// the single toast. <paramref name="siteReachable"/> is a redundant
|
||||
/// cross-check on the same signal (the contract sets it <c>false</c> only
|
||||
/// for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>); it is folded
|
||||
/// INTO the <see cref="SiteCallRelayOutcome.OperationFailed"/> case rather
|
||||
/// than firing a second toast — an <c>OperationFailed</c> response that also
|
||||
/// reports an unreachable site shows the unreachable wording, once.
|
||||
/// </remarks>
|
||||
private void ShowRelayOutcome(
|
||||
SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage)
|
||||
{
|
||||
switch (outcome)
|
||||
{
|
||||
case SiteCallRelayOutcome.Applied:
|
||||
_toast.ShowSuccess(appliedMessage);
|
||||
break;
|
||||
case SiteCallRelayOutcome.NotParked:
|
||||
_toast.ShowInfo(errorMessage
|
||||
?? "The site reported nothing to do — the cached call is no longer parked.");
|
||||
break;
|
||||
case SiteCallRelayOutcome.SiteUnreachable:
|
||||
_toast.ShowError(errorMessage
|
||||
?? "Site unreachable — the relay did not reach the owning site. "
|
||||
+ "Try again once the site is back online.");
|
||||
break;
|
||||
case SiteCallRelayOutcome.OperationFailed when !siteReachable:
|
||||
// An OperationFailed response that nonetheless reports the site
|
||||
// unreachable: trust the reachability signal and show the
|
||||
// unreachable wording instead of the generic failure message.
|
||||
_toast.ShowError(errorMessage
|
||||
?? "Site unreachable — the relay did not reach the owning site. "
|
||||
+ "Try again once the site is back online.");
|
||||
break;
|
||||
case SiteCallRelayOutcome.OperationFailed:
|
||||
default:
|
||||
_toast.ShowError(errorMessage ?? "The site could not apply the action.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowDetail(SiteCallSummary c)
|
||||
{
|
||||
// The summary fields render immediately from the grid row; the full detail
|
||||
// (HttpStatus, all timestamps, LastError) fills in once the fetch completes.
|
||||
_detailSiteCall = c;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await CommunicationService.GetSiteCallDetailAsync(
|
||||
new SiteCallDetailRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId));
|
||||
if (response.Success && response.Detail != null)
|
||||
{
|
||||
_detail = response.Detail;
|
||||
}
|
||||
else
|
||||
{
|
||||
_detailError = response.ErrorMessage ?? "Failed to load site call detail.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_detailError = $"Failed to load site call detail: {ex.Message}";
|
||||
}
|
||||
_detailLoading = false;
|
||||
}
|
||||
|
||||
private void CloseDetail()
|
||||
{
|
||||
_detailSiteCall = null;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = false;
|
||||
}
|
||||
|
||||
private async Task RetryFromDetail(SiteCallSummary c)
|
||||
{
|
||||
await RetrySiteCall(c);
|
||||
// RefreshAll replaces the row list; close the modal so the user sees the
|
||||
// refreshed grid rather than a now-stale detail snapshot.
|
||||
CloseDetail();
|
||||
}
|
||||
|
||||
private async Task DiscardFromDetail(SiteCallSummary c)
|
||||
{
|
||||
await DiscardSiteCall(c);
|
||||
CloseDetail();
|
||||
}
|
||||
|
||||
private void ClearFilters()
|
||||
{
|
||||
_statusFilter = string.Empty;
|
||||
_channelFilter = string.Empty;
|
||||
_siteFilter = string.Empty;
|
||||
_targetFilter = string.Empty;
|
||||
_stuckOnly = false;
|
||||
_fromFilter = null;
|
||||
_toFilter = null;
|
||||
}
|
||||
|
||||
private bool HasActiveFilters =>
|
||||
!string.IsNullOrEmpty(_statusFilter) ||
|
||||
!string.IsNullOrEmpty(_channelFilter) ||
|
||||
!string.IsNullOrEmpty(_siteFilter) ||
|
||||
!string.IsNullOrEmpty(_targetFilter) ||
|
||||
_stuckOnly ||
|
||||
_fromFilter != null ||
|
||||
_toFilter != null;
|
||||
|
||||
private string SiteName(string siteId) =>
|
||||
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
|
||||
|
||||
private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||
|
||||
/// <summary>
|
||||
/// The filter inputs are UTC wall-clock — stamp <see cref="DateTimeKind.Utc"/>
|
||||
/// on the local-typed value so the query is unambiguous.
|
||||
/// </summary>
|
||||
private static DateTime? ToUtc(DateTime? value) =>
|
||||
value == null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// The <c>SiteCalls</c> timestamps are UTC <see cref="DateTime"/>; wrap them as
|
||||
/// a <see cref="DateTimeOffset"/> for <c>TimestampDisplay</c>.
|
||||
/// </summary>
|
||||
private static DateTimeOffset? AsOffset(DateTime? value) =>
|
||||
value == null
|
||||
? null
|
||||
: new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc));
|
||||
|
||||
// A Guid's "N" format is always exactly 32 hex chars, so the [..12] slice is
|
||||
// always in range — no length guard needed.
|
||||
private static string ShortId(Guid id) => id.ToString("N")[..12];
|
||||
|
||||
private static string StatusBadgeClass(string status) => status switch
|
||||
{
|
||||
"Delivered" => "bg-success",
|
||||
"Parked" => "bg-danger",
|
||||
"Failed" => "bg-danger",
|
||||
"Attempted" => "bg-warning text-dark",
|
||||
"Forwarded" => "bg-info text-dark",
|
||||
"Submitted" => "bg-info text-dark",
|
||||
"Discarded" => "bg-secondary",
|
||||
_ => "bg-light text-dark"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@typeparam TValue
|
||||
@*
|
||||
Compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||
summarises the current selection over a checkbox menu. Replaces a wrapped
|
||||
block of chip buttons with a single control of one row's height.
|
||||
*@
|
||||
<div class="dropdown msd" data-test="@DataTest">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
aria-expanded="false"
|
||||
disabled="@(Items.Count == 0)"
|
||||
data-test="@($"{DataTest}-toggle")">
|
||||
<span class="msd-summary">@Summary()</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu msd-menu">
|
||||
@if (Items.Count == 0)
|
||||
{
|
||||
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
var isSelected = Selected.Contains(item);
|
||||
<li>
|
||||
<label class="dropdown-item msd-item">
|
||||
<input type="checkbox"
|
||||
class="form-check-input msd-check"
|
||||
checked="@isSelected"
|
||||
@onchange="() => Toggle(item)"
|
||||
data-test="@($"{DataTest}-opt-{item}")" />
|
||||
<span>@Display(item)</span>
|
||||
</label>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||
/// summarises the current selection ("All" when empty, the single item's label
|
||||
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
|
||||
///
|
||||
/// <para>
|
||||
/// It exists to keep multi-value filter controls one row tall instead of a
|
||||
/// wrapped block of chip buttons. The component mutates the caller-owned
|
||||
/// <see cref="Selected"/> collection in place and raises
|
||||
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
|
||||
/// (re-render, prune dependent selections, …).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
|
||||
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
|
||||
/// while the operator ticks several boxes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
|
||||
public partial class MultiSelectDropdown<TValue> where TValue : notnull
|
||||
{
|
||||
/// <summary>The options shown in the menu, in display order.</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
|
||||
|
||||
/// <summary>
|
||||
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired]
|
||||
public ICollection<TValue> Selected { get; set; } = default!;
|
||||
|
||||
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
|
||||
[Parameter]
|
||||
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
|
||||
|
||||
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
|
||||
[Parameter]
|
||||
public EventCallback SelectionChanged { get; set; }
|
||||
|
||||
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
|
||||
[Parameter]
|
||||
public string AllLabel { get; set; } = "All";
|
||||
|
||||
/// <summary>Text shown in the menu when there are no options.</summary>
|
||||
[Parameter]
|
||||
public string EmptyText { get; set; } = "None available";
|
||||
|
||||
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
|
||||
[Parameter]
|
||||
public string DataTest { get; set; } = "multi-select";
|
||||
|
||||
private async Task Toggle(TValue item)
|
||||
{
|
||||
// ICollection.Remove returns false when the item was absent — that is the
|
||||
// "not currently selected" case, so add it. This is a plain toggle.
|
||||
if (!Selected.Remove(item))
|
||||
{
|
||||
Selected.Add(item);
|
||||
}
|
||||
|
||||
await SelectionChanged.InvokeAsync();
|
||||
}
|
||||
|
||||
private string Summary()
|
||||
{
|
||||
var count = Selected.Count;
|
||||
if (count == 0)
|
||||
{
|
||||
return AllLabel;
|
||||
}
|
||||
|
||||
if (count == 1)
|
||||
{
|
||||
// Prefer the single selection's label over a bare "1 selected".
|
||||
foreach (var item in Items)
|
||||
{
|
||||
if (Selected.Contains(item))
|
||||
{
|
||||
return Display(item);
|
||||
}
|
||||
}
|
||||
|
||||
// The one selected value is not in the current Items list (e.g. a Kind
|
||||
// narrowed out by a Channel change before the parent pruned it).
|
||||
return "1 selected";
|
||||
}
|
||||
|
||||
return $"{count} selected";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
|
||||
form-control-sm controls in a filter row. */
|
||||
|
||||
.msd-toggle {
|
||||
min-width: 9rem;
|
||||
max-width: 15rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep a long option list from running off-screen — scroll within the menu. */
|
||||
.msd-menu {
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
|
||||
menu stays open thanks to data-bs-auto-close="outside". */
|
||||
.msd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Neutralise the default form-check-input top margin so the box lines up with
|
||||
the option text inside the dropdown-item. */
|
||||
.msd-check {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Audit results grid column UX (#23 follow-ups Task 10).
|
||||
//
|
||||
// A tiny, dependency-free helper for the AuditResultsGrid component:
|
||||
// - drag-to-resize: a pointer-driven handle on each <th>'s right edge,
|
||||
// - drag-to-reorder: native HTML5 drag-and-drop on the header row,
|
||||
// - save/load: a sessionStorage round-trip, mirroring treeview-storage.js.
|
||||
//
|
||||
// The Blazor component owns the column model; this file is purely the
|
||||
// browser-side drag plumbing. After a resize or reorder it calls back into
|
||||
// .NET via a DotNetObjectReference so the component can persist + re-render.
|
||||
//
|
||||
// No drag-drop libraries — hand-rolled pointer + native-DnD handlers only.
|
||||
window.auditGrid = {
|
||||
// --- sessionStorage wrapper (mirrors window.treeviewStorage) -----------
|
||||
// Keys are namespaced under "auditGrid:" so they never collide with the
|
||||
// treeview's "treeview:" namespace.
|
||||
save: function (key, json) {
|
||||
try {
|
||||
sessionStorage.setItem("auditGrid:" + key, json);
|
||||
} catch {
|
||||
// Quota / privacy-mode failures are non-fatal — the grid simply
|
||||
// falls back to defaults on the next load.
|
||||
}
|
||||
},
|
||||
|
||||
load: function (key) {
|
||||
try {
|
||||
return sessionStorage.getItem("auditGrid:" + key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Minimum column width in pixels. A column can never be dragged narrower
|
||||
// than this so a header can't collapse to an unclickable sliver.
|
||||
minWidth: 64,
|
||||
|
||||
// --- wire-up ----------------------------------------------------------
|
||||
// `table` is the <table> element, `dotNet` is a DotNetObjectReference
|
||||
// exposing OnColumnResized / OnColumnReordered. Safe to call on every
|
||||
// render: it re-scans the header and binds only cells not already bound,
|
||||
// and always refreshes the live .NET reference. Handlers read the column
|
||||
// key live from data-col-key at event time, so Blazor reusing a <th> DOM
|
||||
// node for a different column (after a reorder re-render) is harmless.
|
||||
init: function (table, dotNet) {
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
table.__auditGridDotNet = dotNet;
|
||||
|
||||
var headerRow = table.tHead && table.tHead.rows[0];
|
||||
if (!headerRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < headerRow.cells.length; i++) {
|
||||
this._bindHeaderCell(table, headerRow.cells[i]);
|
||||
}
|
||||
},
|
||||
|
||||
// Bind resize + reorder handlers to a single <th>. Idempotent — a cell
|
||||
// already carrying handlers is skipped. The handlers resolve the column
|
||||
// key live (th.getAttribute) so they stay correct if the renderer reuses
|
||||
// the element for another column.
|
||||
_bindHeaderCell: function (table, th) {
|
||||
var self = this;
|
||||
if (th.__auditGridCellBound) {
|
||||
return;
|
||||
}
|
||||
th.__auditGridCellBound = true;
|
||||
|
||||
// --- resize: pointer drag on the handle ---------------------------
|
||||
var handle = th.querySelector(".audit-grid-resize-handle");
|
||||
if (handle) {
|
||||
handle.addEventListener("pointerdown", function (ev) {
|
||||
ev.preventDefault();
|
||||
// Stop the pointerdown from also starting a header drag.
|
||||
ev.stopPropagation();
|
||||
|
||||
var startX = ev.clientX;
|
||||
var startWidth = th.getBoundingClientRect().width;
|
||||
handle.setPointerCapture(ev.pointerId);
|
||||
th.classList.add("resizing");
|
||||
|
||||
function onMove(moveEv) {
|
||||
var next = Math.max(self.minWidth, startWidth + (moveEv.clientX - startX));
|
||||
self._applyWidth(th, next);
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
handle.releasePointerCapture(ev.pointerId);
|
||||
handle.removeEventListener("pointermove", onMove);
|
||||
handle.removeEventListener("pointerup", onUp);
|
||||
handle.removeEventListener("pointercancel", onUp);
|
||||
th.classList.remove("resizing");
|
||||
|
||||
var key = th.getAttribute("data-col-key");
|
||||
var finalWidth = Math.round(th.getBoundingClientRect().width);
|
||||
var dn = table.__auditGridDotNet;
|
||||
if (key && dn) {
|
||||
dn.invokeMethodAsync("OnColumnResized", key, finalWidth);
|
||||
}
|
||||
}
|
||||
|
||||
handle.addEventListener("pointermove", onMove);
|
||||
handle.addEventListener("pointerup", onUp);
|
||||
handle.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// --- reorder: native HTML5 drag-and-drop on the header ------------
|
||||
// The whole <th> is draggable; dropping it onto another header swaps
|
||||
// the dragged column into the drop target's position.
|
||||
th.setAttribute("draggable", "true");
|
||||
|
||||
th.addEventListener("dragstart", function (ev) {
|
||||
// A resize in progress sets .resizing; never start a reorder then.
|
||||
if (th.classList.contains("resizing")) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
var key = th.getAttribute("data-col-key");
|
||||
if (!key) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
table.__auditGridDragKey = key;
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
// Some browsers require data to be set for the drag to begin.
|
||||
try { ev.dataTransfer.setData("text/plain", key); } catch { /* ignore */ }
|
||||
th.classList.add("dragging");
|
||||
});
|
||||
|
||||
th.addEventListener("dragend", function () {
|
||||
th.classList.remove("dragging");
|
||||
table.__auditGridDragKey = null;
|
||||
self._clearDropTargets(table);
|
||||
});
|
||||
|
||||
th.addEventListener("dragover", function (ev) {
|
||||
// Allowing the drop is what lets dragover/drop fire at all.
|
||||
var key = th.getAttribute("data-col-key");
|
||||
if (key && table.__auditGridDragKey && table.__auditGridDragKey !== key) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = "move";
|
||||
th.classList.add("drop-target");
|
||||
}
|
||||
});
|
||||
|
||||
th.addEventListener("dragleave", function () {
|
||||
th.classList.remove("drop-target");
|
||||
});
|
||||
|
||||
th.addEventListener("drop", function (ev) {
|
||||
ev.preventDefault();
|
||||
th.classList.remove("drop-target");
|
||||
var key = th.getAttribute("data-col-key");
|
||||
var fromKey = table.__auditGridDragKey;
|
||||
table.__auditGridDragKey = null;
|
||||
if (!key || !fromKey || fromKey === key) {
|
||||
return;
|
||||
}
|
||||
var dn = table.__auditGridDotNet;
|
||||
if (dn) {
|
||||
// fromKey moves to occupy toKey's slot; the component computes
|
||||
// the resulting order and re-renders + persists.
|
||||
dn.invokeMethodAsync("OnColumnReordered", fromKey, key);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Apply a width to a <th> via a CSS custom property. The scoped stylesheet
|
||||
// reads --audit-col-width; absent it, the column falls back to auto.
|
||||
//
|
||||
// Known, intentional behaviour: during a live resize drag this updates the
|
||||
// <th> width immediately, but the <td> body cells only catch up on the next
|
||||
// .NET re-render (driven by OnColumnResized at pointer-up). The brief
|
||||
// header/body width mismatch mid-drag is an accepted trade-off for an
|
||||
// internal tool — not a bug.
|
||||
_applyWidth: function (th, widthPx) {
|
||||
th.style.setProperty("--audit-col-width", widthPx + "px");
|
||||
},
|
||||
|
||||
_clearDropTargets: function (table) {
|
||||
var hits = table.querySelectorAll(".drop-target, .dragging");
|
||||
for (var i = 0; i < hits.length; i++) {
|
||||
hits[i].classList.remove("drop-target", "dragging");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -26,6 +26,13 @@ public sealed record AuditEvent
|
||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||
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>
|
||||
|
||||
@@ -63,4 +63,27 @@ public interface ISiteCallAuditRepository
|
||||
/// deleted.
|
||||
/// </summary>
|
||||
Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a point-in-time global <see cref="SiteCallKpiSnapshot"/> from the
|
||||
/// <c>SiteCalls</c> table. Counts are aggregated server-side (no row
|
||||
/// materialisation): <c>StuckCount</c> uses <paramref name="stuckCutoff"/>;
|
||||
/// <c>FailedLastInterval</c> / <c>DeliveredLastInterval</c> use
|
||||
/// <paramref name="intervalSince"/>; the current time for <c>OldestPendingAge</c>
|
||||
/// is captured inside the method.
|
||||
/// </summary>
|
||||
Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff,
|
||||
DateTime intervalSince,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a point-in-time <see cref="SiteCallSiteKpiSnapshot"/> per source
|
||||
/// site. Sites with no <c>SiteCalls</c> rows at all are omitted. The stuck
|
||||
/// cutoff and interval bounds are interpreted as in <see cref="ComputeKpisAsync"/>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff,
|
||||
DateTime intervalSince,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,20 @@ public interface ICachedCallLifecycleObserver
|
||||
/// <param name="OccurredAtUtc">When this attempt completed.</param>
|
||||
/// <param name="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&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&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&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&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&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>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.Commons.Messages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Site Calls UI -> Central: paginated, filtered query over the central
|
||||
/// <c>SiteCalls</c> table (Site Call Audit #22). All filter fields are optional;
|
||||
/// <see cref="StuckOnly"/> restricts results to stuck cached calls. Mirrors
|
||||
/// <see cref="ScadaLink.Commons.Messages.Notification.NotificationOutboxQueryRequest"/>
|
||||
/// but uses keyset paging (<see cref="AfterCreatedAtUtc"/> + <see cref="AfterId"/>)
|
||||
/// to match the repository's <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c>
|
||||
/// cursor, rather than page numbers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ChannelFilter"/> matches the <c>SiteCall.Channel</c> column —
|
||||
/// <c>"ApiOutbound"</c> or <c>"DbOutbound"</c> (the spec's <c>Kind</c> notion;
|
||||
/// the entity exposes it as <c>Channel</c>). <see cref="TargetKeyword"/> is an
|
||||
/// exact-match target filter, consistent with the repository's
|
||||
/// <see cref="SiteCallQueryFilter.Target"/> predicate.
|
||||
/// </remarks>
|
||||
/// <param name="PageSize">
|
||||
/// Requested page size. The actor clamps this to the <c>[1, 200]</c> range, so
|
||||
/// the effective ceiling is 200 rows per page regardless of the value sent.
|
||||
/// </param>
|
||||
public sealed record SiteCallQueryRequest(
|
||||
string CorrelationId,
|
||||
string? StatusFilter,
|
||||
string? SourceSiteFilter,
|
||||
string? ChannelFilter,
|
||||
string? TargetKeyword,
|
||||
bool StuckOnly,
|
||||
DateTime? FromUtc,
|
||||
DateTime? ToUtc,
|
||||
DateTime? AfterCreatedAtUtc,
|
||||
Guid? AfterId,
|
||||
int PageSize);
|
||||
|
||||
/// <summary>
|
||||
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
|
||||
/// only the columns the <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/>
|
||||
/// entity genuinely exposes — there are no source-instance/script provenance
|
||||
/// columns on that entity, so unlike
|
||||
/// <see cref="ScadaLink.Commons.Messages.Notification.NotificationSummary"/>
|
||||
/// none are surfaced here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="HttpStatus"/> is not called out in the Site Call Audit plan, but
|
||||
/// it is a real (nullable) <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/>
|
||||
/// column — the last HTTP status code observed for the call — so it is surfaced
|
||||
/// here for the grid; <c>null</c> for non-HTTP channels or before a first attempt.
|
||||
/// </remarks>
|
||||
public sealed record SiteCallSummary(
|
||||
Guid TrackedOperationId,
|
||||
string SourceSite,
|
||||
string Channel,
|
||||
string Target,
|
||||
string Status,
|
||||
int RetryCount,
|
||||
string? LastError,
|
||||
int? HttpStatus,
|
||||
DateTime CreatedAtUtc,
|
||||
DateTime UpdatedAtUtc,
|
||||
DateTime? TerminalAtUtc,
|
||||
bool IsStuck);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
|
||||
/// The keyset cursor of the last row is echoed back as
|
||||
/// <see cref="NextAfterCreatedAtUtc"/> + <see cref="NextAfterId"/> for the caller
|
||||
/// to request the following page; both are <c>null</c> when the page was empty.
|
||||
/// On a repository fault <see cref="Success"/> is <c>false</c>,
|
||||
/// <see cref="ErrorMessage"/> carries the cause and <see cref="SiteCalls"/> is empty.
|
||||
/// </summary>
|
||||
public sealed record SiteCallQueryResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
IReadOnlyList<SiteCallSummary> SiteCalls,
|
||||
DateTime? NextAfterCreatedAtUtc,
|
||||
Guid? NextAfterId);
|
||||
|
||||
/// <summary>
|
||||
/// Site Calls UI -> Central: request for the full detail of a single cached call,
|
||||
/// for the report detail modal.
|
||||
/// </summary>
|
||||
public sealed record SiteCallDetailRequest(
|
||||
string CorrelationId,
|
||||
Guid TrackedOperationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site Calls UI: full detail for one cached call. On a repository
|
||||
/// fault or missing row, <see cref="Success"/> is <c>false</c> /
|
||||
/// <see cref="Detail"/> is <c>null</c> and <see cref="ErrorMessage"/> carries
|
||||
/// the cause.
|
||||
/// </summary>
|
||||
public sealed record SiteCallDetailResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
SiteCallDetail? Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Full <c>SiteCalls</c> row detail for the report detail modal — every field
|
||||
/// on the <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> entity,
|
||||
/// including <see cref="LastError"/> and the <see cref="IngestedAtUtc"/>
|
||||
/// timestamp the grid summary omits.
|
||||
/// </summary>
|
||||
public sealed record SiteCallDetail(
|
||||
Guid TrackedOperationId,
|
||||
string SourceSite,
|
||||
string Channel,
|
||||
string Target,
|
||||
string Status,
|
||||
int RetryCount,
|
||||
string? LastError,
|
||||
int? HttpStatus,
|
||||
DateTime CreatedAtUtc,
|
||||
DateTime UpdatedAtUtc,
|
||||
DateTime? TerminalAtUtc,
|
||||
DateTime IngestedAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Site Calls UI -> Central: request for the global <c>SiteCalls</c> KPI summary.
|
||||
/// Mirrors <see cref="ScadaLink.Commons.Messages.Notification.NotificationKpiRequest"/>.
|
||||
/// </summary>
|
||||
public sealed record SiteCallKpiRequest(
|
||||
string CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site Calls UI: KPI summary for the Site Calls dashboard. On a
|
||||
/// repository fault <see cref="Success"/> is <c>false</c>,
|
||||
/// <see cref="ErrorMessage"/> carries the cause, and the KPI fields are
|
||||
/// zeroed/<c>null</c>.
|
||||
/// </summary>
|
||||
public sealed record SiteCallKpiResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
int BufferedCount,
|
||||
int ParkedCount,
|
||||
int FailedLastInterval,
|
||||
int DeliveredLastInterval,
|
||||
TimeSpan? OldestPendingAge,
|
||||
int StuckCount);
|
||||
|
||||
/// <summary>
|
||||
/// Site Calls UI -> Central: request for the per-source-site <c>SiteCalls</c>
|
||||
/// KPI breakdown. Mirrors
|
||||
/// <see cref="ScadaLink.Commons.Messages.Notification.PerSiteNotificationKpiRequest"/>.
|
||||
/// </summary>
|
||||
public sealed record PerSiteSiteCallKpiRequest(
|
||||
string CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site Calls UI: per-site KPI breakdown for the Site Calls KPIs
|
||||
/// page. On a repository fault <see cref="Success"/> is <c>false</c>,
|
||||
/// <see cref="ErrorMessage"/> carries the cause, and <see cref="Sites"/> is empty.
|
||||
/// </summary>
|
||||
public sealed record PerSiteSiteCallKpiResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
IReadOnlyList<SiteCallSiteKpiSnapshot> Sites);
|
||||
@@ -0,0 +1,113 @@
|
||||
namespace ScadaLink.Commons.Messages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a Site Call Audit (#22) Retry/Discard relay — distinguishes the
|
||||
/// three cases the Central UI Site Calls page must surface differently.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The "site unreachable" case is deliberately separate from
|
||||
/// <see cref="OperationFailed"/>: central is an eventually-consistent mirror,
|
||||
/// not the source of truth, so a relay that never reaches the owning site is a
|
||||
/// transient transport condition the operator can retry — not a failed
|
||||
/// operation. The UI shows "site unreachable" rather than a generic error.
|
||||
/// </remarks>
|
||||
public enum SiteCallRelayOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// The owning site received the relay command and applied the action to its
|
||||
/// Store-and-Forward buffer (the parked cached call was reset to retry, or
|
||||
/// discarded). The corrected state reaches central later via telemetry.
|
||||
/// </summary>
|
||||
Applied,
|
||||
|
||||
/// <summary>
|
||||
/// The owning site received the relay command but found nothing to do — no
|
||||
/// parked row matched the tracked id (already delivered/discarded, or no
|
||||
/// longer <c>Parked</c>). A definitive answer from the site, not a failure.
|
||||
/// </summary>
|
||||
NotParked,
|
||||
|
||||
/// <summary>
|
||||
/// The owning site could not be reached (offline / no ClusterClient route /
|
||||
/// relay timed out). The action was NOT applied; the operator may retry once
|
||||
/// the site is back online.
|
||||
/// </summary>
|
||||
SiteUnreachable,
|
||||
|
||||
/// <summary>
|
||||
/// The owning site was reached but reported it could not apply the action
|
||||
/// (its parked-message handler was unavailable or its store faulted).
|
||||
/// </summary>
|
||||
OperationFailed,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Central UI → Site Call Audit: relay a Retry of a parked cached call to its
|
||||
/// owning site. The owning site performs the actual retry on its
|
||||
/// Store-and-Forward buffer — central never mutates the central <c>SiteCalls</c>
|
||||
/// mirror row. Mirrors
|
||||
/// <see cref="ScadaLink.Commons.Messages.Notification.RetryNotificationRequest"/>
|
||||
/// but carries <see cref="SourceSite"/> (the relay target) and answers with a
|
||||
/// distinct site-unreachable outcome.
|
||||
/// </summary>
|
||||
/// <param name="CorrelationId">Request correlation id, echoed on the response.</param>
|
||||
/// <param name="TrackedOperationId">
|
||||
/// The cached operation to retry — the PK of the central <c>SiteCalls</c> row
|
||||
/// and the S&F buffer message id at the owning site.
|
||||
/// </param>
|
||||
/// <param name="SourceSite">
|
||||
/// The owning site (<c>SiteCall.SourceSite</c>) the relay is routed to.
|
||||
/// </param>
|
||||
public sealed record RetrySiteCallRequest(
|
||||
string CorrelationId,
|
||||
Guid TrackedOperationId,
|
||||
string SourceSite);
|
||||
|
||||
/// <summary>
|
||||
/// Site Call Audit → Central UI: result of a <see cref="RetrySiteCallRequest"/>.
|
||||
/// </summary>
|
||||
/// <param name="CorrelationId">Echoed request correlation id.</param>
|
||||
/// <param name="Outcome">
|
||||
/// The relay outcome — <see cref="SiteCallRelayOutcome.Applied"/>,
|
||||
/// <see cref="SiteCallRelayOutcome.NotParked"/>,
|
||||
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> or
|
||||
/// <see cref="SiteCallRelayOutcome.OperationFailed"/>.
|
||||
/// </param>
|
||||
/// <param name="Success">
|
||||
/// Convenience flag — <c>true</c> only for <see cref="SiteCallRelayOutcome.Applied"/>.
|
||||
/// </param>
|
||||
/// <param name="SiteReachable">
|
||||
/// <c>false</c> only for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>; lets
|
||||
/// the UI distinguish "site offline" from "operation failed" without switching
|
||||
/// on the enum.
|
||||
/// </param>
|
||||
/// <param name="ErrorMessage">
|
||||
/// Human-readable detail for a non-applied outcome; <c>null</c> on success.
|
||||
/// </param>
|
||||
public sealed record RetrySiteCallResponse(
|
||||
string CorrelationId,
|
||||
SiteCallRelayOutcome Outcome,
|
||||
bool Success,
|
||||
bool SiteReachable,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Central UI → Site Call Audit: relay a Discard of a parked cached call to its
|
||||
/// owning site. See <see cref="RetrySiteCallRequest"/> for the source-of-truth
|
||||
/// and routing rationale.
|
||||
/// </summary>
|
||||
public sealed record DiscardSiteCallRequest(
|
||||
string CorrelationId,
|
||||
Guid TrackedOperationId,
|
||||
string SourceSite);
|
||||
|
||||
/// <summary>
|
||||
/// Site Call Audit → Central UI: result of a <see cref="DiscardSiteCallRequest"/>.
|
||||
/// Same shape as <see cref="RetrySiteCallResponse"/>.
|
||||
/// </summary>
|
||||
public sealed record DiscardSiteCallResponse(
|
||||
string CorrelationId,
|
||||
SiteCallRelayOutcome Outcome,
|
||||
bool Success,
|
||||
bool SiteReachable,
|
||||
string? ErrorMessage);
|
||||
@@ -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.
|
||||
|
||||
@@ -76,6 +76,49 @@ public record DiscardNotificationResponse(
|
||||
bool Success,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Outbox UI -> Central: request for the full detail of a single notification
|
||||
/// (including Body and resolved recipients), for the report detail modal.
|
||||
/// </summary>
|
||||
public record NotificationDetailRequest(
|
||||
string CorrelationId,
|
||||
string NotificationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Outbox UI: full detail for one notification. On a repository fault or
|
||||
/// missing row, Success is false / Detail is null and ErrorMessage carries the cause.
|
||||
/// </summary>
|
||||
public record NotificationDetailResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
NotificationDetail? Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Full notification detail for the report detail modal — everything in the grid's
|
||||
/// NotificationSummary plus Body, ResolvedTargets (recipients), TypeData, SourceScript,
|
||||
/// and the additional lifecycle timestamps.
|
||||
/// </summary>
|
||||
public record NotificationDetail(
|
||||
string NotificationId,
|
||||
string Type,
|
||||
string ListName,
|
||||
string Subject,
|
||||
string Body,
|
||||
string Status,
|
||||
int RetryCount,
|
||||
string? LastError,
|
||||
string? ResolvedTargets,
|
||||
string? TypeData,
|
||||
string SourceSiteId,
|
||||
string? SourceInstanceId,
|
||||
string? SourceScript,
|
||||
DateTimeOffset SiteEnqueuedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? LastAttemptAt,
|
||||
DateTimeOffset? NextAttemptAt,
|
||||
DateTimeOffset? DeliveredAt);
|
||||
|
||||
/// <summary>
|
||||
/// Outbox UI -> Central: request for the notification outbox KPI summary.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.Commons.Messages.RemoteQuery;
|
||||
|
||||
/// <summary>
|
||||
/// Central → site relay command: retry a parked cached operation
|
||||
/// (<c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c>) on the
|
||||
/// owning site's Store-and-Forward buffer. Sent over the command/control
|
||||
/// channel by <c>SiteCallAuditActor</c> when an operator clicks Retry on a
|
||||
/// <c>Parked</c> Site Call row in the Central UI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The site is the source of truth for cached-call status — central never
|
||||
/// mutates the central <c>SiteCalls</c> mirror row directly. This command asks
|
||||
/// the site to reset its own parked row back to <c>Pending</c> so the S&F
|
||||
/// retry sweep attempts delivery again; the corrected state then flows back to
|
||||
/// central via the normal cached-call telemetry path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cached call's S&F buffer message id is the
|
||||
/// <see cref="TrackedOperationId"/> itself (the tracked id is supplied as the
|
||||
/// buffered row's id at enqueue time), so the site can resolve the parked row
|
||||
/// directly from <see cref="TrackedOperationId"/>. A retry on a row that is not
|
||||
/// actually <c>Parked</c> is a safe no-op at the site — the ack reports
|
||||
/// <c>Applied=false</c> rather than corrupting a non-parked row.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is a plain record carrying only ids, so it lives in Commons (no
|
||||
/// <c>IActorRef</c> field). It mirrors <see cref="ParkedMessageRetryRequest"/>
|
||||
/// but keys on <see cref="TrackedOperationId"/> rather than the opaque S&F
|
||||
/// message-id string.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record RetryParkedOperation(
|
||||
string CorrelationId,
|
||||
TrackedOperationId TrackedOperationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central → site relay command: discard a parked cached operation on the
|
||||
/// owning site's Store-and-Forward buffer. Sent over the command/control
|
||||
/// channel by <c>SiteCallAuditActor</c> when an operator clicks Discard on a
|
||||
/// <c>Parked</c> Site Call row in the Central UI. See
|
||||
/// <see cref="RetryParkedOperation"/> for the source-of-truth and message-id
|
||||
/// rationale; Discard marks the operation terminally <c>Discarded</c> at the
|
||||
/// site by removing the parked S&F buffer row.
|
||||
/// </summary>
|
||||
public sealed record DiscardParkedOperation(
|
||||
string CorrelationId,
|
||||
TrackedOperationId TrackedOperationId);
|
||||
|
||||
/// <summary>
|
||||
/// Site → central ack for a <see cref="RetryParkedOperation"/> /
|
||||
/// <see cref="DiscardParkedOperation"/> relay command. The site replies this
|
||||
/// after applying (or safely no-op-ing) the action against its own
|
||||
/// Store-and-Forward buffer.
|
||||
/// </summary>
|
||||
/// <param name="CorrelationId">Correlation id of the originating relay command.</param>
|
||||
/// <param name="Applied">
|
||||
/// <c>true</c> when the parked operation was found and the action was applied;
|
||||
/// <c>false</c> when no parked row matched the <see cref="RetryParkedOperation.TrackedOperationId"/>
|
||||
/// (already delivered, discarded, never cached, or not in a <c>Parked</c>
|
||||
/// state). A <c>false</c> ack is a definitive "nothing to do" answer from the
|
||||
/// site — it is NOT a transport failure, so the relay must distinguish it from
|
||||
/// a site-unreachable timeout.
|
||||
/// </param>
|
||||
/// <param name="ErrorMessage">
|
||||
/// Populated only when the site could not apply the action (e.g. the parked
|
||||
/// message handler is not available, or the S&F store faulted); <c>null</c>
|
||||
/// on a clean <c>Applied=true</c>/<c>Applied=false</c> outcome.
|
||||
/// </param>
|
||||
public sealed record ParkedOperationActionAck(
|
||||
string CorrelationId,
|
||||
bool Applied,
|
||||
string? ErrorMessage = null);
|
||||
@@ -4,18 +4,25 @@ namespace ScadaLink.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// 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>>=</c> / <c><=</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>>=</c> / <c><=</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<string, StringValues></c> from <c>QueryHelpers.ParseQuery</c>,
|
||||
/// etc.) and passes them here as a plain <see cref="IEnumerable{T}"/> of strings —
|
||||
/// so this helper carries NO ASP.NET / <c>Microsoft.Extensions.Primitives</c>
|
||||
/// dependency and can live in <c>ScadaLink.Commons</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Lax-parse contract.</b> Every value of a repeated parameter is parsed
|
||||
/// independently; an unparseable or blank element is silently dropped (NO 400)
|
||||
/// rather than failing the whole set. An empty result collapses to <c>null</c> so
|
||||
/// the corresponding filter dimension stays unconstrained.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AuditQueryParamParsers
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses each raw value as <typeparamref name="TEnum"/> (case-insensitive),
|
||||
/// dropping unparseable values silently. Returns <c>null</c> when
|
||||
/// <paramref name="rawValues"/> is <c>null</c>, empty, or yields no parseable
|
||||
/// value — so the filter dimension stays unconstrained.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IEnumerable<string?>? rawValues)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (rawValues is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<TEnum>();
|
||||
foreach (var raw in rawValues)
|
||||
{
|
||||
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
||||
{
|
||||
parsed.Add(value);
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims each raw value and drops blank entries. Returns <c>null</c> when
|
||||
/// <paramref name="rawValues"/> is <c>null</c>, empty, or every value was
|
||||
/// blank.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string>? ParseStringList(IEnumerable<string?>? rawValues)
|
||||
{
|
||||
if (rawValues is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<string>();
|
||||
foreach (var raw in rawValues)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
parsed.Add(raw.Trim());
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ScadaLink.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time operational metrics for the central <c>SiteCalls</c> table
|
||||
/// (Site Call Audit #22), surfaced on the health dashboard. The cached-call
|
||||
/// counterpart of <see cref="ScadaLink.Commons.Types.Notifications.NotificationKpiSnapshot"/>;
|
||||
/// mirrors its shape so the Central UI Site Calls KPI tiles can reuse the
|
||||
/// Notification Outbox tile layout.
|
||||
/// </summary>
|
||||
/// <param name="BufferedCount">
|
||||
/// Count of non-terminal rows (<c>TerminalAtUtc IS NULL</c>) — calls
|
||||
/// buffered at sites awaiting retry.
|
||||
/// </param>
|
||||
/// <param name="ParkedCount">Count of rows in the <c>Parked</c> status.</param>
|
||||
/// <param name="FailedLastInterval">
|
||||
/// Count of <c>Failed</c> rows whose <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.TerminalAtUtc"/>
|
||||
/// is at or after the supplied "since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="DeliveredLastInterval">
|
||||
/// Count of <c>Delivered</c> rows whose <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.TerminalAtUtc"/>
|
||||
/// is at or after the supplied "since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="OldestPendingAge">
|
||||
/// Age of the oldest non-terminal row (<c>now - min(CreatedAtUtc)</c>), or
|
||||
/// <c>null</c> when there are no non-terminal rows.
|
||||
/// </param>
|
||||
/// <param name="StuckCount">
|
||||
/// Count of non-terminal rows (<c>TerminalAtUtc IS NULL</c>) whose
|
||||
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.CreatedAtUtc"/> is older
|
||||
/// than the supplied stuck cutoff. Display-only — no escalation.
|
||||
/// </param>
|
||||
public sealed record SiteCallKpiSnapshot(
|
||||
int BufferedCount,
|
||||
int ParkedCount,
|
||||
int FailedLastInterval,
|
||||
int DeliveredLastInterval,
|
||||
TimeSpan? OldestPendingAge,
|
||||
int StuckCount);
|
||||
@@ -12,10 +12,25 @@ namespace ScadaLink.Commons.Types.Audit;
|
||||
/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
|
||||
/// page exposes them as drop-down filters, not free-text search.
|
||||
/// </remarks>
|
||||
/// <param name="Channel">Restrict to a single channel (exact match).</param>
|
||||
/// <param name="SourceSite">Restrict to a single source site (exact match).</param>
|
||||
/// <param name="Status">Restrict to a single status (exact match).</param>
|
||||
/// <param name="Target">Restrict to a single target (exact match).</param>
|
||||
/// <param name="FromUtc">Inclusive lower bound on <c>CreatedAtUtc</c>.</param>
|
||||
/// <param name="ToUtc">Inclusive upper bound on <c>CreatedAtUtc</c>.</param>
|
||||
/// <param name="StuckCutoffUtc">
|
||||
/// When set, restrict to stuck rows: <c>TerminalAtUtc IS NULL AND CreatedAtUtc <
|
||||
/// StuckCutoffUtc</c>. Both columns are plain (no value converter) and compose
|
||||
/// directly with the keyset cursor. Mirrors
|
||||
/// <see cref="ScadaLink.Commons.Types.Notifications.NotificationOutboxFilter.StuckCutoff"/>;
|
||||
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
|
||||
/// pages with a non-null next cursor.
|
||||
/// </param>
|
||||
public sealed record SiteCallQueryFilter(
|
||||
string? Channel = null,
|
||||
string? SourceSite = null,
|
||||
string? Status = null,
|
||||
string? Target = null,
|
||||
DateTime? FromUtc = null,
|
||||
DateTime? ToUtc = null);
|
||||
DateTime? ToUtc = null,
|
||||
DateTime? StuckCutoffUtc = null);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ScadaLink.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time <c>SiteCalls</c> metrics scoped to a single source site. The
|
||||
/// per-site counterpart of <see cref="SiteCallKpiSnapshot"/>; surfaced in the
|
||||
/// per-site breakdown table on the Site Calls KPIs page. Mirrors
|
||||
/// <see cref="ScadaLink.Commons.Types.Notifications.SiteNotificationKpiSnapshot"/>.
|
||||
/// </summary>
|
||||
/// <param name="SourceSite">The site identifier these metrics are scoped to.</param>
|
||||
/// <param name="BufferedCount">Count of this site's non-terminal rows (<c>TerminalAtUtc IS NULL</c>).</param>
|
||||
/// <param name="ParkedCount">Count of this site's rows in the <c>Parked</c> status.</param>
|
||||
/// <param name="FailedLastInterval">
|
||||
/// Count of this site's <c>Failed</c> rows whose <c>TerminalAtUtc</c> is at or
|
||||
/// after the "since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="DeliveredLastInterval">
|
||||
/// Count of this site's <c>Delivered</c> rows whose <c>TerminalAtUtc</c> is at
|
||||
/// or after the "since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="OldestPendingAge">
|
||||
/// Age of this site's oldest non-terminal row, or <c>null</c> when it has none.
|
||||
/// </param>
|
||||
/// <param name="StuckCount">
|
||||
/// Count of this site's non-terminal rows whose <c>CreatedAtUtc</c> is older
|
||||
/// than the stuck cutoff.
|
||||
/// </param>
|
||||
public sealed record SiteCallSiteKpiSnapshot(
|
||||
string SourceSite,
|
||||
int BufferedCount,
|
||||
int ParkedCount,
|
||||
int FailedLastInterval,
|
||||
int DeliveredLastInterval,
|
||||
TimeSpan? OldestPendingAge,
|
||||
int StuckCount);
|
||||
@@ -5,6 +5,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.Event;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Messages.Communication;
|
||||
using ScadaLink.Commons.Messages.Health;
|
||||
using ScadaLink.Commons.Messages.Notification;
|
||||
@@ -76,6 +77,43 @@ public class CentralCommunicationActor : ReceiveActor
|
||||
/// </summary>
|
||||
private IActorRef? _notificationOutboxProxy;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy <see cref="IActorRef"/> for the central AuditLogIngestActor cluster
|
||||
/// singleton. Set via <see cref="RegisterAuditIngest"/> — the Host creates the
|
||||
/// singleton proxy after this actor and registers it (mirrors
|
||||
/// <see cref="_notificationOutboxProxy"/>). Null until registration completes;
|
||||
/// an audit ingest command arriving before then is answered with an empty
|
||||
/// reply so the site keeps its rows Pending and retries.
|
||||
///
|
||||
/// Once registered, the handler Asks this proxy and pipes the reply straight
|
||||
/// back to the caller. On an Ask timeout or a faulted reply, PipeTo forwards a
|
||||
/// <see cref="Status.Failure"/> to the caller — the fault propagates rather
|
||||
/// than being swallowed. This differs from the gRPC handler
|
||||
/// (<c>SiteStreamGrpcServer</c>), which catches the exception and returns an
|
||||
/// empty ack; here the faulted Ask is the transient signal the site relies on
|
||||
/// (see <see cref="HandleIngestAuditEvents"/>).
|
||||
/// </summary>
|
||||
private IActorRef? _auditIngestProxy;
|
||||
|
||||
/// <summary>
|
||||
/// Default Ask timeout for routing audit ingest commands to the
|
||||
/// AuditLogIngestActor proxy — 30 s, matching the value of
|
||||
/// <c>SiteStreamGrpcServer.AuditIngestAskTimeout</c> (that constant is private
|
||||
/// to the gRPC server and not reachable here, so it is declared locally). A
|
||||
/// generous window absorbs a slow MS SQL connection without the round-trip
|
||||
/// surfacing as a failure on a healthy site. When the window is exceeded the
|
||||
/// Ask faults and that fault is piped back to the caller as a
|
||||
/// <see cref="Status.Failure"/> (see <see cref="HandleIngestAuditEvents"/>).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DefaultAuditIngestAskTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Effective Ask timeout for audit ingest routing. Defaults to
|
||||
/// <see cref="DefaultAuditIngestAskTimeout"/>; overridable via the constructor
|
||||
/// so tests can exercise the timeout/fault path without waiting 30 s.
|
||||
/// </summary>
|
||||
private readonly TimeSpan _auditIngestAskTimeout;
|
||||
|
||||
/// <summary>
|
||||
/// DistributedPubSub topic used to fan health reports out to the peer
|
||||
/// central node so both per-node aggregators stay in sync. See
|
||||
@@ -83,10 +121,19 @@ public class CentralCommunicationActor : ReceiveActor
|
||||
/// </summary>
|
||||
private const string HealthReportTopic = "site-health-replica";
|
||||
|
||||
public CentralCommunicationActor(IServiceProvider serviceProvider, ISiteClientFactory siteClientFactory)
|
||||
/// <param name="auditIngestAskTimeout">
|
||||
/// Optional override for the audit-ingest Ask timeout; defaults to
|
||||
/// <see cref="DefaultAuditIngestAskTimeout"/> (30 s). Exists only so tests can
|
||||
/// exercise the timeout/fault path quickly — production always uses the default.
|
||||
/// </param>
|
||||
public CentralCommunicationActor(
|
||||
IServiceProvider serviceProvider,
|
||||
ISiteClientFactory siteClientFactory,
|
||||
TimeSpan? auditIngestAskTimeout = null)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_siteClientFactory = siteClientFactory;
|
||||
_auditIngestAskTimeout = auditIngestAskTimeout ?? DefaultAuditIngestAskTimeout;
|
||||
|
||||
// Site address cache loaded from database
|
||||
Receive<SiteAddressCacheLoaded>(HandleSiteAddressCacheLoaded);
|
||||
@@ -133,6 +180,24 @@ public class CentralCommunicationActor : ReceiveActor
|
||||
// so the NotificationStatusResponse routes back to the querying site.
|
||||
Receive<NotificationStatusQuery>(HandleNotificationStatusQuery);
|
||||
|
||||
// Audit Log (#23): the Host registers the AuditLogIngestActor singleton
|
||||
// proxy after this actor is created (the proxy cannot exist before this
|
||||
// actor's construction).
|
||||
Receive<RegisterAuditIngest>(msg =>
|
||||
{
|
||||
_auditIngestProxy = msg.AuditIngestActor;
|
||||
_log.Info("Registered audit ingest proxy");
|
||||
});
|
||||
|
||||
// Audit Log (#23) site→central ingest: a site forwards a batch of audit
|
||||
// events to the central cluster via ClusterClient. Ask the ingest proxy
|
||||
// and pipe the IngestAuditEventsReply back to the original Sender (the
|
||||
// site's ClusterClient path) so the site can flip its rows to Forwarded.
|
||||
Receive<IngestAuditEventsCommand>(HandleIngestAuditEvents);
|
||||
|
||||
// Audit Log (#23 M3) combined-telemetry ingest: routes to the same proxy
|
||||
// the same way; the proxy replies with an IngestCachedTelemetryReply.
|
||||
Receive<IngestCachedTelemetryCommand>(HandleIngestCachedTelemetry);
|
||||
}
|
||||
|
||||
private void HandleNotificationSubmit(NotificationSubmit msg)
|
||||
@@ -172,6 +237,51 @@ public class CentralCommunicationActor : ReceiveActor
|
||||
_notificationOutboxProxy.Forward(msg);
|
||||
}
|
||||
|
||||
private void HandleIngestAuditEvents(IngestAuditEventsCommand msg)
|
||||
{
|
||||
if (_auditIngestProxy == null)
|
||||
{
|
||||
// No ingest proxy registered yet (host startup race). Reply with an
|
||||
// empty IngestAuditEventsReply so the site keeps its rows Pending and
|
||||
// retries — the same behaviour as the gRPC handler's wiring-race path.
|
||||
_log.Warning(
|
||||
"Cannot route IngestAuditEventsCommand ({0} events) — audit ingest not available",
|
||||
msg.Events.Count);
|
||||
Sender.Tell(new IngestAuditEventsReply(Array.Empty<Guid>()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture Sender before the async/PipeTo — Akka resets Sender between
|
||||
// dispatches. The reply is piped straight back to the site's ClusterClient.
|
||||
// On an Ask timeout or a faulted reply, PipeTo delivers a Status.Failure to
|
||||
// replyTo: the fault propagates to the caller rather than being swallowed.
|
||||
// The site's own Ask through this path then faults, and the site drain loop
|
||||
// treats that as a transient failure — rows stay Pending and are retried on
|
||||
// the next tick. (The gRPC handler instead returns an empty ack on fault;
|
||||
// propagating the fault here is the cleaner transient signal.)
|
||||
var replyTo = Sender;
|
||||
_log.Debug("Routing IngestAuditEventsCommand ({0} events) to the audit ingest actor", msg.Events.Count);
|
||||
_auditIngestProxy.Ask<IngestAuditEventsReply>(msg, _auditIngestAskTimeout)
|
||||
.PipeTo(replyTo);
|
||||
}
|
||||
|
||||
private void HandleIngestCachedTelemetry(IngestCachedTelemetryCommand msg)
|
||||
{
|
||||
if (_auditIngestProxy == null)
|
||||
{
|
||||
_log.Warning(
|
||||
"Cannot route IngestCachedTelemetryCommand ({0} entries) — audit ingest not available",
|
||||
msg.Entries.Count);
|
||||
Sender.Tell(new IngestCachedTelemetryReply(Array.Empty<Guid>()));
|
||||
return;
|
||||
}
|
||||
|
||||
var replyTo = Sender;
|
||||
_log.Debug("Routing IngestCachedTelemetryCommand ({0} entries) to the audit ingest actor", msg.Entries.Count);
|
||||
_auditIngestProxy.Ask<IngestCachedTelemetryReply>(msg, _auditIngestAskTimeout)
|
||||
.PipeTo(replyTo);
|
||||
}
|
||||
|
||||
private void HandleHeartbeat(HeartbeatMessage heartbeat)
|
||||
{
|
||||
var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>();
|
||||
@@ -464,3 +574,14 @@ public record DebugStreamTerminated(string SiteId, string CorrelationId);
|
||||
/// after the outbox singleton proxy is created.
|
||||
/// </summary>
|
||||
public record RegisterNotificationOutbox(IActorRef OutboxProxy);
|
||||
|
||||
/// <summary>
|
||||
/// Registers the central AuditLogIngestActor singleton proxy with the
|
||||
/// <see cref="CentralCommunicationActor"/> so site-forwarded
|
||||
/// <see cref="IngestAuditEventsCommand"/> and <see cref="IngestCachedTelemetryCommand"/>
|
||||
/// messages can be routed to it. Sent by the Host after the audit-ingest
|
||||
/// singleton proxy is created. Lives here (not in Commons) because
|
||||
/// <c>ScadaLink.Commons</c> has no Akka package reference and cannot hold an
|
||||
/// <see cref="IActorRef"/> field.
|
||||
/// </summary>
|
||||
public sealed record RegisterAuditIngest(IActorRef AuditIngestActor);
|
||||
|
||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Akka.Cluster.Tools.Client;
|
||||
using Akka.Event;
|
||||
using ScadaLink.Commons.Messages.Artifacts;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Messages.DebugView;
|
||||
using ScadaLink.Commons.Messages.Deployment;
|
||||
using ScadaLink.Commons.Messages.Health;
|
||||
@@ -166,6 +167,33 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
});
|
||||
|
||||
// Task 5 (#22): central→site Retry/Discard relay for parked cached
|
||||
// operations. SiteCallAuditActor relays these over the command/control
|
||||
// channel; the parked-message handler executes them against the local
|
||||
// S&F buffer and replies a ParkedOperationActionAck that routes back to
|
||||
// the relaying SiteCallAuditActor's Ask.
|
||||
Receive<RetryParkedOperation>(msg =>
|
||||
{
|
||||
if (_parkedMessageHandler != null)
|
||||
_parkedMessageHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
Sender.Tell(new ParkedOperationActionAck(
|
||||
msg.CorrelationId, Applied: false, "Parked message handler not available"));
|
||||
}
|
||||
});
|
||||
|
||||
Receive<DiscardParkedOperation>(msg =>
|
||||
{
|
||||
if (_parkedMessageHandler != null)
|
||||
_parkedMessageHandler.Forward(msg);
|
||||
else
|
||||
{
|
||||
Sender.Tell(new ParkedOperationActionAck(
|
||||
msg.CorrelationId, Applied: false, "Parked message handler not available"));
|
||||
}
|
||||
});
|
||||
|
||||
// Notification Outbox: forward a buffered notification submitted by the site
|
||||
// Store-and-Forward Engine to the central cluster. The original Sender (the
|
||||
// S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the
|
||||
@@ -214,6 +242,54 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
||||
new ClusterClient.Send("/user/central-communication", msg), Sender);
|
||||
});
|
||||
|
||||
// Audit Log (#23): forward a batch of site-local audit events to the
|
||||
// central cluster. The site SiteAuditTelemetryActor drains its SQLite
|
||||
// Pending queue through the ClusterClientSiteAuditClient, which Asks
|
||||
// this actor; the original Sender (that Ask) is passed as the
|
||||
// ClusterClient.Send sender so the IngestAuditEventsReply routes
|
||||
// straight back to the waiting Ask, not here. Mirrors NotificationSubmit.
|
||||
Receive<IngestAuditEventsCommand>(msg =>
|
||||
{
|
||||
if (_centralClient == null)
|
||||
{
|
||||
// No ClusterClient registered yet (e.g. central contact points
|
||||
// not configured, or registration not yet completed). Faulting
|
||||
// the Ask makes the SiteAuditTelemetryActor drain loop treat
|
||||
// this as transient and keep the rows Pending for the next tick.
|
||||
_log.Warning(
|
||||
"Cannot forward IngestAuditEventsCommand ({0} events) — no central ClusterClient registered",
|
||||
msg.Events.Count);
|
||||
Sender.Tell(new Status.Failure(
|
||||
new InvalidOperationException("Central ClusterClient not registered")));
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Debug("Forwarding IngestAuditEventsCommand ({0} events) to central", msg.Events.Count);
|
||||
_centralClient.Tell(
|
||||
new ClusterClient.Send("/user/central-communication", msg), Sender);
|
||||
});
|
||||
|
||||
// Audit Log (#23) M3: forward a batch of combined cached-call telemetry
|
||||
// packets to the central cluster. Same forward + reply-routing pattern
|
||||
// as IngestAuditEventsCommand; central replies with an
|
||||
// IngestCachedTelemetryReply.
|
||||
Receive<IngestCachedTelemetryCommand>(msg =>
|
||||
{
|
||||
if (_centralClient == null)
|
||||
{
|
||||
_log.Warning(
|
||||
"Cannot forward IngestCachedTelemetryCommand ({0} entries) — no central ClusterClient registered",
|
||||
msg.Entries.Count);
|
||||
Sender.Tell(new Status.Failure(
|
||||
new InvalidOperationException("Central ClusterClient not registered")));
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Debug("Forwarding IngestCachedTelemetryCommand ({0} entries) to central", msg.Entries.Count);
|
||||
_centralClient.Tell(
|
||||
new ClusterClient.Send("/user/central-communication", msg), Sender);
|
||||
});
|
||||
|
||||
// Internal: send heartbeat tick
|
||||
Receive<SendHeartbeat>(_ => SendHeartbeatToCentral());
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.Commons.Messages.Artifacts;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Messages.DebugView;
|
||||
using ScadaLink.Commons.Messages.Deployment;
|
||||
using ScadaLink.Commons.Messages.Health;
|
||||
@@ -25,6 +26,7 @@ public class CommunicationService
|
||||
private readonly ILogger<CommunicationService> _logger;
|
||||
private IActorRef? _centralCommunicationActor;
|
||||
private IActorRef? _notificationOutboxProxy;
|
||||
private IActorRef? _siteCallAuditProxy;
|
||||
|
||||
public CommunicationService(
|
||||
IOptions<CommunicationOptions> options,
|
||||
@@ -52,6 +54,17 @@ public class CommunicationService
|
||||
_notificationOutboxProxy = notificationOutboxProxy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Site Call Audit (#22) singleton proxy reference. Called during
|
||||
/// actor system startup. The Site Call Audit actor is central-local, so Site
|
||||
/// Calls read calls Ask this proxy directly (no SiteEnvelope routing), the
|
||||
/// same pattern as <see cref="SetNotificationOutbox"/>.
|
||||
/// </summary>
|
||||
public void SetSiteCallAudit(IActorRef siteCallAuditProxy)
|
||||
{
|
||||
_siteCallAuditProxy = siteCallAuditProxy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers an immediate refresh of the site address cache from the database.
|
||||
/// </summary>
|
||||
@@ -80,6 +93,15 @@ public class CommunicationService
|
||||
?? throw new InvalidOperationException("CommunicationService not initialized. NotificationOutbox proxy not set.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Site Call Audit proxy reference. Throws if not yet initialized.
|
||||
/// </summary>
|
||||
private IActorRef GetSiteCallAudit()
|
||||
{
|
||||
return _siteCallAuditProxy
|
||||
?? throw new InvalidOperationException("CommunicationService not initialized. SiteCallAudit proxy not set.");
|
||||
}
|
||||
|
||||
// ── Pattern 1: Instance Deployment ──
|
||||
|
||||
public async Task<DeploymentStatusResponse> DeployInstanceAsync(
|
||||
@@ -275,6 +297,13 @@ public class CommunicationService
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NotificationDetailResponse> GetNotificationDetailAsync(
|
||||
NotificationDetailRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetNotificationOutbox().Ask<NotificationDetailResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NotificationKpiResponse> GetNotificationKpisAsync(
|
||||
NotificationKpiRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -288,6 +317,71 @@ public class CommunicationService
|
||||
return await GetNotificationOutbox().Ask<PerSiteNotificationKpiResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
// ── Site Call Audit (central-local actor — Asked directly, no SiteEnvelope) ──
|
||||
|
||||
public async Task<SiteCallQueryResponse> QuerySiteCallsAsync(
|
||||
SiteCallQueryRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetSiteCallAudit().Ask<SiteCallQueryResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SiteCallDetailResponse> GetSiteCallDetailAsync(
|
||||
SiteCallDetailRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetSiteCallAudit().Ask<SiteCallDetailResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SiteCallKpiResponse> GetSiteCallKpisAsync(
|
||||
SiteCallKpiRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetSiteCallAudit().Ask<SiteCallKpiResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PerSiteSiteCallKpiResponse> GetPerSiteSiteCallKpisAsync(
|
||||
PerSiteSiteCallKpiRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetSiteCallAudit().Ask<PerSiteSiteCallKpiResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 5 (#22): relays an operator Retry of a parked cached call to its
|
||||
/// owning site. The <c>SiteCallAuditActor</c> is Asked directly (it is
|
||||
/// central-local); it in turn relays a <c>RetryParkedOperation</c> to the
|
||||
/// owning site and replies a <see cref="RetrySiteCallResponse"/> carrying a
|
||||
/// distinct site-unreachable outcome. Central never mutates the central
|
||||
/// <c>SiteCalls</c> mirror row.
|
||||
/// <para>
|
||||
/// This outer Ask uses <see cref="CommunicationOptions.QueryTimeout"/>
|
||||
/// (default 30s), which must outlive the inner site relay Ask the
|
||||
/// <c>SiteCallAuditActor</c> issues with <c>SiteCallAuditOptions.RelayTimeout</c>
|
||||
/// (default 10s). The inner relay must time out first so its distinct
|
||||
/// <c>SiteUnreachable</c> outcome reaches us; were this outer Ask to expire
|
||||
/// first, that outcome would be lost to a generic Ask-timeout exception.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<RetrySiteCallResponse> RetrySiteCallAsync(
|
||||
RetrySiteCallRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetSiteCallAudit().Ask<RetrySiteCallResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 5 (#22): relays an operator Discard of a parked cached call to its
|
||||
/// owning site. See <see cref="RetrySiteCallAsync"/> for the routing and
|
||||
/// source-of-truth rationale.
|
||||
/// </summary>
|
||||
public async Task<DiscardSiteCallResponse> DiscardSiteCallAsync(
|
||||
DiscardSiteCallRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetSiteCallAudit().Ask<DiscardSiteCallResponse>(
|
||||
request, _options.QueryTimeout, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+16
-6
@@ -1,16 +1,24 @@
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
||||
|
||||
namespace ScadaLink.AuditLog.Telemetry;
|
||||
namespace ScadaLink.Communication.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges Audit Log (#23) rows between the in-process <see cref="AuditEvent"/> record
|
||||
/// and the wire-format <see cref="AuditEventDto"/> exchanged over the
|
||||
/// <c>IngestAuditEvents</c> RPC.
|
||||
/// Canonical bridge for Audit Log (#23) rows between the in-process
|
||||
/// <see cref="AuditEvent"/> record and the wire-format <see cref="AuditEventDto"/>
|
||||
/// exchanged over the <c>IngestAuditEvents</c>, <c>IngestCachedTelemetry</c> and
|
||||
/// <c>PullAuditEvents</c> RPCs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This mapper lives in <c>ScadaLink.Communication</c> (which owns the generated
|
||||
/// <see cref="AuditEventDto"/> and references <c>Commons</c> for
|
||||
/// <see cref="AuditEvent"/>) so both <c>SiteStreamGrpcServer</c> and
|
||||
/// <c>ScadaLink.AuditLog</c> can share one implementation without the
|
||||
/// project-reference cycle that would result from hosting it in
|
||||
/// <c>ScadaLink.AuditLog</c> (AuditLog → Communication, never the reverse).
|
||||
/// </para>
|
||||
/// <para><b>Lossy by design:</b> the proto contract intentionally omits two fields.</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="AuditEvent.ForwardState"/> — site-local SQLite state, never travels.</item>
|
||||
@@ -22,7 +30,7 @@ namespace ScadaLink.AuditLog.Telemetry;
|
||||
/// <c>Int32Value</c> wrapper so they preserve true null semantics.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AuditEventMapper
|
||||
public static class AuditEventDtoMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects an <see cref="AuditEvent"/> into its wire-format DTO. Null reference
|
||||
@@ -39,6 +47,7 @@ public static class AuditEventMapper
|
||||
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,
|
||||
@@ -84,6 +93,7 @@ public static class AuditEventMapper
|
||||
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),
|
||||
@@ -0,0 +1,70 @@
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.Communication.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical bridge for Site Call Audit (#22) operational rows between the
|
||||
/// wire-format <see cref="SiteCallOperationalDto"/> exchanged on the
|
||||
/// <c>CachedCallTelemetry</c> packet and the in-process <see cref="SiteCall"/>
|
||||
/// persistence entity central writes into the <c>SiteCalls</c> table.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This mapper lives in <c>ScadaLink.Communication</c> (which owns the generated
|
||||
/// <see cref="SiteCallOperationalDto"/> and references <c>Commons</c> for
|
||||
/// <see cref="SiteCall"/>) so both <c>SiteStreamGrpcServer</c> and
|
||||
/// <c>ScadaLink.AuditLog</c> can share one implementation without the
|
||||
/// project-reference cycle that would result from hosting it in
|
||||
/// <c>ScadaLink.AuditLog</c> (AuditLog → Communication, never the reverse).
|
||||
/// Mirrors the sibling <see cref="AuditEventDtoMapper"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only the DTO→entity direction is provided: nothing in the system maps a
|
||||
/// <see cref="SiteCall"/> back onto the wire (sites emit the operational state
|
||||
/// from <c>SiteCallOperational</c>, never from the central <see cref="SiteCall"/>
|
||||
/// entity), so an entity→DTO method would be dead code.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// String nullability convention: proto3 scalar strings cannot be absent, so the
|
||||
/// optional <see cref="SiteCall.LastError"/> rehydrates from an empty string back
|
||||
/// to null. The optional <c>HttpStatus</c> and <c>TerminalAtUtc</c> use proto
|
||||
/// wrappers so they preserve true null semantics.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SiteCallDtoMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Reconstructs a <see cref="SiteCall"/> persistence entity from its
|
||||
/// wire-format DTO. An empty <c>LastError</c> rehydrates as null; absent
|
||||
/// <c>HttpStatus</c>/<c>TerminalAtUtc</c> wrappers stay null.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="SiteCall.IngestedAtUtc"/> is stamped here as a placeholder
|
||||
/// (<see cref="DateTime.UtcNow"/>); the central ingest actor overwrites it
|
||||
/// inside the dual-write transaction so the AuditLog and SiteCalls rows
|
||||
/// share one instant. The value sent on the wire is informational only.
|
||||
/// </remarks>
|
||||
public static SiteCall FromDto(SiteCallOperationalDto dto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dto);
|
||||
|
||||
return new SiteCall
|
||||
{
|
||||
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
|
||||
Channel = dto.Channel,
|
||||
Target = dto.Target,
|
||||
SourceSite = dto.SourceSite,
|
||||
Status = dto.Status,
|
||||
RetryCount = dto.RetryCount,
|
||||
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
|
||||
HttpStatus = dto.HttpStatus,
|
||||
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
TerminalAtUtc = dto.TerminalAtUtc is null
|
||||
? null
|
||||
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ using Microsoft.Extensions.Options;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using GrpcStatus = Grpc.Core.Status;
|
||||
|
||||
namespace ScadaLink.Communication.Grpc;
|
||||
@@ -224,13 +222,10 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The DTO→entity conversion is inlined here (rather than calling the
|
||||
/// AuditLog mapper) to avoid a project-reference cycle:
|
||||
/// <c>ScadaLink.AuditLog</c> already references
|
||||
/// <c>ScadaLink.Communication</c>, so the gRPC server cannot reach back
|
||||
/// into AuditLog for its mapper. The shape mirrors
|
||||
/// <c>AuditEventMapper.FromDto</c> in <c>ScadaLink.AuditLog.Telemetry</c>;
|
||||
/// the two must evolve together.
|
||||
/// The DTO→entity conversion uses the shared <see cref="AuditEventDtoMapper"/>
|
||||
/// (hosted in <c>ScadaLink.Communication</c> so both this server and
|
||||
/// <c>ScadaLink.AuditLog</c> share one implementation without a
|
||||
/// project-reference cycle).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see cref="_auditIngestActor"/> is not yet wired (host startup
|
||||
@@ -262,36 +257,10 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
return new IngestAck();
|
||||
}
|
||||
|
||||
// Inlined FromDto. Keep in sync with AuditEventMapper.FromDto in
|
||||
// ScadaLink.AuditLog.Telemetry — there is no shared mapper because
|
||||
// doing so would create a project-reference cycle (AuditLog → Communication).
|
||||
var entities = new List<AuditEvent>(request.Events.Count);
|
||||
foreach (var dto in request.Events)
|
||||
{
|
||||
entities.Add(new AuditEvent
|
||||
{
|
||||
EventId = Guid.Parse(dto.EventId),
|
||||
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
IngestedAtUtc = null,
|
||||
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
||||
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
||||
CorrelationId = string.IsNullOrEmpty(dto.CorrelationId) ? null : Guid.Parse(dto.CorrelationId),
|
||||
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
||||
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
||||
SourceScript = NullIfEmpty(dto.SourceScript),
|
||||
Actor = NullIfEmpty(dto.Actor),
|
||||
Target = NullIfEmpty(dto.Target),
|
||||
Status = Enum.Parse<AuditStatus>(dto.Status),
|
||||
HttpStatus = dto.HttpStatus,
|
||||
DurationMs = dto.DurationMs,
|
||||
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
|
||||
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
|
||||
RequestSummary = NullIfEmpty(dto.RequestSummary),
|
||||
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
|
||||
PayloadTruncated = dto.PayloadTruncated,
|
||||
Extra = NullIfEmpty(dto.Extra),
|
||||
ForwardState = null,
|
||||
});
|
||||
entities.Add(AuditEventDtoMapper.FromDto(dto));
|
||||
}
|
||||
|
||||
var cmd = new IngestAuditEventsCommand(entities);
|
||||
@@ -355,8 +324,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
var entries = new List<CachedTelemetryEntry>(request.Packets.Count);
|
||||
foreach (var packet in request.Packets)
|
||||
{
|
||||
var auditEvent = MapAuditEventFromDto(packet.AuditEvent);
|
||||
var siteCall = MapSiteCallFromDto(packet.Operational);
|
||||
var auditEvent = AuditEventDtoMapper.FromDto(packet.AuditEvent);
|
||||
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
|
||||
entries.Add(new CachedTelemetryEntry(auditEvent, siteCall));
|
||||
}
|
||||
|
||||
@@ -450,7 +419,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
};
|
||||
foreach (var evt in events)
|
||||
{
|
||||
response.Events.Add(AuditEventToDto(evt));
|
||||
response.Events.Add(AuditEventDtoMapper.ToDto(evt));
|
||||
}
|
||||
|
||||
// Flip to Reconciled AFTER projecting the response so a fault below the
|
||||
@@ -481,110 +450,6 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inlined audit-event entity→DTO translation. Keep in sync with
|
||||
/// <c>AuditEventMapper.ToDto</c> in <c>ScadaLink.AuditLog.Telemetry</c> —
|
||||
/// the project-reference cycle (AuditLog → Communication) prevents calling
|
||||
/// the AuditLog mapper directly. The shape mirrors the FromDto pair above.
|
||||
/// </summary>
|
||||
private static AuditEventDto AuditEventToDto(AuditEvent evt)
|
||||
{
|
||||
var dto = new AuditEventDto
|
||||
{
|
||||
EventId = evt.EventId.ToString(),
|
||||
OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
|
||||
Channel = evt.Channel.ToString(),
|
||||
Kind = evt.Kind.ToString(),
|
||||
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
||||
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
||||
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
||||
SourceScript = evt.SourceScript ?? string.Empty,
|
||||
Actor = evt.Actor ?? string.Empty,
|
||||
Target = evt.Target ?? string.Empty,
|
||||
Status = evt.Status.ToString(),
|
||||
ErrorMessage = evt.ErrorMessage ?? string.Empty,
|
||||
ErrorDetail = evt.ErrorDetail ?? string.Empty,
|
||||
RequestSummary = evt.RequestSummary ?? string.Empty,
|
||||
ResponseSummary = evt.ResponseSummary ?? string.Empty,
|
||||
PayloadTruncated = evt.PayloadTruncated,
|
||||
Extra = evt.Extra ?? string.Empty,
|
||||
};
|
||||
|
||||
if (evt.HttpStatus.HasValue) dto.HttpStatus = evt.HttpStatus.Value;
|
||||
if (evt.DurationMs.HasValue) dto.DurationMs = evt.DurationMs.Value;
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private static DateTime EnsureUtc(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc
|
||||
? value
|
||||
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
|
||||
|
||||
private static string? NullIfEmpty(string? value) =>
|
||||
string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
/// <summary>
|
||||
/// Inlined audit-event DTO→entity translation, kept in sync with the
|
||||
/// <see cref="IngestAuditEvents"/> handler above. Extracted to a private
|
||||
/// helper so the M3 dual-write RPC can reuse it without duplicating yet
|
||||
/// another copy. The shape still mirrors
|
||||
/// <c>AuditEventMapper.FromDto</c> in <c>ScadaLink.AuditLog.Telemetry</c>;
|
||||
/// the two must evolve together (the project-reference cycle that
|
||||
/// prevents calling the AuditLog mapper directly is documented on
|
||||
/// <see cref="IngestAuditEvents"/>).
|
||||
/// </summary>
|
||||
private static AuditEvent MapAuditEventFromDto(AuditEventDto dto) =>
|
||||
new()
|
||||
{
|
||||
EventId = Guid.Parse(dto.EventId),
|
||||
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
IngestedAtUtc = null,
|
||||
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
||||
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
||||
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
||||
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
||||
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
||||
SourceScript = NullIfEmpty(dto.SourceScript),
|
||||
Actor = NullIfEmpty(dto.Actor),
|
||||
Target = NullIfEmpty(dto.Target),
|
||||
Status = Enum.Parse<AuditStatus>(dto.Status),
|
||||
HttpStatus = dto.HttpStatus,
|
||||
DurationMs = dto.DurationMs,
|
||||
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
|
||||
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
|
||||
RequestSummary = NullIfEmpty(dto.RequestSummary),
|
||||
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
|
||||
PayloadTruncated = dto.PayloadTruncated,
|
||||
Extra = NullIfEmpty(dto.Extra),
|
||||
ForwardState = null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Translates a <see cref="SiteCallOperationalDto"/> into the persistence
|
||||
/// entity. <see cref="SiteCall.IngestedAtUtc"/> is stamped here as a
|
||||
/// placeholder; the central ingest actor overwrites it inside the
|
||||
/// dual-write transaction so the AuditLog and SiteCalls rows share one
|
||||
/// instant.
|
||||
/// </summary>
|
||||
private static SiteCall MapSiteCallFromDto(SiteCallOperationalDto dto) => new()
|
||||
{
|
||||
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
|
||||
Channel = dto.Channel,
|
||||
Target = dto.Target,
|
||||
SourceSite = dto.SourceSite,
|
||||
Status = dto.Status,
|
||||
RetryCount = dto.RetryCount,
|
||||
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
|
||||
HttpStatus = dto.HttpStatus,
|
||||
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
TerminalAtUtc = dto.TerminalAtUtc is null
|
||||
? null
|
||||
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a single active stream so cleanup only removes its own entry.
|
||||
/// </summary>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Generated
+1626
File diff suppressed because it is too large
Load Diff
+57
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1629
File diff suppressed because it is too large
Load Diff
+41
@@ -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];
|
||||
|
||||
|
||||
@@ -164,7 +164,13 @@ WHERE TrackedOperationId = {idText}
|
||||
|
||||
var fromUtc = filter.FromUtc;
|
||||
var toUtc = filter.ToUtc;
|
||||
var stuckCutoff = filter.StuckCutoffUtc;
|
||||
|
||||
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
|
||||
// is pushed into SQL here — both columns are plain (no value converter)
|
||||
// and compose with the keyset cursor, so a StuckOnly page is honest:
|
||||
// never under-filled with a non-null next cursor. Mirrors how
|
||||
// NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff.
|
||||
FormattableString sql = $@"
|
||||
SELECT TOP ({paging.PageSize})
|
||||
TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount,
|
||||
@@ -176,6 +182,7 @@ WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
|
||||
AND ({filter.Target} IS NULL OR Target = {filter.Target})
|
||||
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
|
||||
AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
|
||||
AND ({stuckCutoff} IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < {stuckCutoff}))
|
||||
AND ({(hasCursor ? 1 : 0)} = 0
|
||||
OR CreatedAtUtc < {afterCreated}
|
||||
OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
|
||||
@@ -201,6 +208,141 @@ ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
|
||||
ct);
|
||||
}
|
||||
|
||||
// Terminal status string literals for the interval-throughput KPIs. The
|
||||
// Status column is a plain varchar (no value converter), so these compare
|
||||
// directly in translated SQL.
|
||||
//
|
||||
// NOTE on the "buffered/non-terminal" definition: the SiteCalls operational
|
||||
// mirror stores AuditStatus-derived strings (Attempted/Delivered/Parked/
|
||||
// Failed/...), NOT the tracking-lifecycle Pending/Retrying names the spec's
|
||||
// KPI section uses. There is therefore no Status string that means
|
||||
// "buffered". The schema-honest predicate for "non-terminal / buffered" is
|
||||
// TerminalAtUtc IS NULL — consistent with PurgeTerminalAsync's terminal
|
||||
// predicate and with the SiteCall entity's own contract ("TerminalAtUtc ...
|
||||
// null while still active"). All buffered / stuck / oldest-pending counts
|
||||
// below key off TerminalAtUtc, not Status.
|
||||
private const string StatusParked = "Parked";
|
||||
private const string StatusDelivered = "Delivered";
|
||||
private const string StatusFailed = "Failed";
|
||||
|
||||
/// <summary>
|
||||
/// Computes the global KPI snapshot with five server-side aggregate queries
|
||||
/// against <c>dbo.SiteCalls</c>. No rows are materialised — every count is a
|
||||
/// translated <c>COUNT</c> and the oldest-pending age is a translated
|
||||
/// <c>MIN(CreatedAtUtc)</c>. The <c>Status</c> and <c>CreatedAtUtc</c>/<c>TerminalAtUtc</c>
|
||||
/// columns have no value converter, so the aggregates translate cleanly to
|
||||
/// SQL Server (unlike the NotificationOutbox's <c>DateTimeOffset</c>-converted
|
||||
/// column, which forces an order-and-take). "Buffered" / "stuck" key off
|
||||
/// <c>TerminalAtUtc IS NULL</c> — see the field comments above.
|
||||
/// </summary>
|
||||
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var bufferedCount = await _context.SiteCalls
|
||||
.CountAsync(s => s.TerminalAtUtc == null, ct);
|
||||
|
||||
var parkedCount = await _context.SiteCalls
|
||||
.CountAsync(s => s.Status == StatusParked, ct);
|
||||
|
||||
var failedLastInterval = await _context.SiteCalls
|
||||
.CountAsync(s => s.Status == StatusFailed
|
||||
&& s.TerminalAtUtc != null
|
||||
&& s.TerminalAtUtc >= intervalSince, ct);
|
||||
|
||||
var deliveredLastInterval = await _context.SiteCalls
|
||||
.CountAsync(s => s.Status == StatusDelivered
|
||||
&& s.TerminalAtUtc != null
|
||||
&& s.TerminalAtUtc >= intervalSince, ct);
|
||||
|
||||
var stuckCount = await _context.SiteCalls
|
||||
.CountAsync(s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
|
||||
|
||||
var nonTerminal = _context.SiteCalls.Where(s => s.TerminalAtUtc == null);
|
||||
|
||||
TimeSpan? oldestPendingAge = null;
|
||||
if (await nonTerminal.AnyAsync(ct))
|
||||
{
|
||||
var oldestCreatedAt = await nonTerminal.MinAsync(s => s.CreatedAtUtc, ct);
|
||||
oldestPendingAge = now - oldestCreatedAt;
|
||||
}
|
||||
|
||||
return new SiteCallKpiSnapshot(
|
||||
BufferedCount: bufferedCount,
|
||||
ParkedCount: parkedCount,
|
||||
FailedLastInterval: failedLastInterval,
|
||||
DeliveredLastInterval: deliveredLastInterval,
|
||||
OldestPendingAge: oldestPendingAge,
|
||||
StuckCount: stuckCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the per-source-site KPI breakdown. The five counts are
|
||||
/// <c>GROUP BY SourceSite</c> aggregates; the oldest-pending age is a
|
||||
/// per-site <c>MIN(CreatedAtUtc)</c> over the (bounded) non-terminal set —
|
||||
/// all run server-side. A site appears in the result only if it has at
|
||||
/// least one row matched by one of the count queries. "Buffered" / "stuck"
|
||||
/// key off <c>TerminalAtUtc IS NULL</c> — see <see cref="ComputeKpisAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var buffered = await CountBySiteAsync(s => s.TerminalAtUtc == null, ct);
|
||||
|
||||
var parked = await CountBySiteAsync(s => s.Status == StatusParked, ct);
|
||||
|
||||
var failed = await CountBySiteAsync(
|
||||
s => s.Status == StatusFailed
|
||||
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
|
||||
|
||||
var delivered = await CountBySiteAsync(
|
||||
s => s.Status == StatusDelivered
|
||||
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
|
||||
|
||||
var stuck = await CountBySiteAsync(
|
||||
s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
|
||||
|
||||
// Oldest non-terminal CreatedAtUtc per site — a server-side GROUP BY MIN.
|
||||
var oldest = (await _context.SiteCalls
|
||||
.Where(s => s.TerminalAtUtc == null)
|
||||
.GroupBy(s => s.SourceSite)
|
||||
.Select(g => new { Site = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(x => x.Site, x => x.Oldest);
|
||||
|
||||
var siteIds = buffered.Keys
|
||||
.Concat(parked.Keys).Concat(failed.Keys)
|
||||
.Concat(delivered.Keys).Concat(stuck.Keys)
|
||||
.Distinct()
|
||||
.OrderBy(s => s, StringComparer.Ordinal);
|
||||
|
||||
return siteIds.Select(site => new SiteCallSiteKpiSnapshot(
|
||||
SourceSite: site,
|
||||
BufferedCount: buffered.GetValueOrDefault(site),
|
||||
ParkedCount: parked.GetValueOrDefault(site),
|
||||
FailedLastInterval: failed.GetValueOrDefault(site),
|
||||
DeliveredLastInterval: delivered.GetValueOrDefault(site),
|
||||
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
|
||||
? now - createdAt
|
||||
: null,
|
||||
StuckCount: stuck.GetValueOrDefault(site))).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Counts <c>SiteCalls</c> rows matching <paramref name="predicate"/>, grouped by source site.</summary>
|
||||
private async Task<Dictionary<string, int>> CountBySiteAsync(
|
||||
System.Linq.Expressions.Expression<Func<SiteCall, bool>> predicate,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _context.SiteCalls
|
||||
.Where(predicate)
|
||||
.GroupBy(s => s.SourceSite)
|
||||
.Select(g => new { Site = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Site, x => x.Count, ct);
|
||||
}
|
||||
|
||||
private static int GetRankOrThrow(string status)
|
||||
{
|
||||
if (!StatusRank.TryGetValue(status, out var rank))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -370,6 +370,11 @@ akka {{
|
||||
.WithSingletonName("audit-log-ingest"));
|
||||
var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy");
|
||||
|
||||
// Hand the audit-ingest proxy to the CentralCommunicationActor so audit
|
||||
// ingest commands forwarded by sites over ClusterClient are routed to the
|
||||
// singleton. Mirrors the RegisterNotificationOutbox wiring above.
|
||||
centralCommActor.Tell(new RegisterAuditIngest(auditIngestProxy));
|
||||
|
||||
// Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
|
||||
// so the IngestAuditEvents RPC routes incoming site batches to the singleton.
|
||||
// The gRPC server is currently only registered on Site nodes; on a central
|
||||
@@ -410,18 +415,23 @@ akka {{
|
||||
// and NotificationOutbox patterns. M3's dual-write transaction routes
|
||||
// SiteCalls upserts through AuditLogIngestActor's own scope-per-message
|
||||
// ISiteCallAuditRepository resolution, so this singleton is not on the
|
||||
// M3 happy-path hot path; it exists so future direct-write callers
|
||||
// (reconciliation puller, central→site Retry/Discard relay, KPI
|
||||
// projector) Ask through a stable cluster proxy without further wiring.
|
||||
// M3 happy-path hot path; it exists so direct-write callers Ask through
|
||||
// a stable cluster proxy without further wiring. The central→site
|
||||
// Retry/Discard relay now lives in this actor (see the
|
||||
// RegisterCentralCommunication wiring below); the reconciliation puller
|
||||
// is the remaining deferred direct-write caller.
|
||||
// Like AuditLogIngestActor, the actor takes the root IServiceProvider
|
||||
// and creates a fresh scope per message because ISiteCallAuditRepository
|
||||
// is a scoped EF Core service.
|
||||
var siteCallAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger<ScadaLink.SiteCallAudit.SiteCallAuditActor>();
|
||||
var siteCallAuditOptions = _serviceProvider
|
||||
.GetRequiredService<IOptions<ScadaLink.SiteCallAudit.SiteCallAuditOptions>>().Value;
|
||||
|
||||
var siteCallAuditSingletonProps = ClusterSingletonManager.Props(
|
||||
singletonProps: Props.Create(() => new ScadaLink.SiteCallAudit.SiteCallAuditActor(
|
||||
_serviceProvider,
|
||||
siteCallAuditOptions,
|
||||
siteCallAuditLogger)),
|
||||
terminationMessage: PoisonPill.Instance,
|
||||
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
||||
@@ -432,8 +442,23 @@ akka {{
|
||||
singletonManagerPath: "/user/site-call-audit-singleton",
|
||||
settings: ClusterSingletonProxySettings.Create(_actorSystem)
|
||||
.WithSingletonName("site-call-audit"));
|
||||
_actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy");
|
||||
_logger.LogInformation("SiteCallAuditActor singleton created");
|
||||
var siteCallAuditProxy = _actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy");
|
||||
|
||||
// Hand the proxy to the CommunicationService so the Central UI can Ask
|
||||
// the Site Call Audit actor directly (query, KPIs, detail) — mirrors the
|
||||
// SetNotificationOutbox wiring above.
|
||||
commService?.SetSiteCallAudit(siteCallAuditProxy);
|
||||
|
||||
// Task 5 (#22): hand the CentralCommunicationActor to the SiteCallAudit
|
||||
// actor so it can relay operator Retry/Discard on parked cached calls to
|
||||
// the owning site (over the per-site ClusterClient via SiteEnvelope).
|
||||
// Mirrors the RegisterAuditIngest / RegisterNotificationOutbox wiring;
|
||||
// the message is sent to the singleton proxy so it reaches whichever
|
||||
// central node currently hosts the singleton.
|
||||
siteCallAuditProxy.Tell(
|
||||
new ScadaLink.SiteCallAudit.RegisterCentralCommunication(centralCommActor));
|
||||
_logger.LogInformation(
|
||||
"SiteCallAuditActor singleton created and registered with CentralCommunicationActor");
|
||||
|
||||
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
|
||||
}
|
||||
@@ -656,15 +681,26 @@ 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
|
||||
.GetRequiredService<ScadaLink.Commons.Interfaces.Services.ISiteAuditQueue>();
|
||||
var siteAuditClient = _serviceProvider
|
||||
.GetRequiredService<ScadaLink.AuditLog.Site.Telemetry.ISiteStreamAuditClient>();
|
||||
// Audit Log (#23) Task 2 follow-up: the production site→central audit
|
||||
// push uses the ClusterClient transport via the SiteCommunicationActor,
|
||||
// not the DI-resolved NoOpSiteStreamAuditClient. The NoOp default stays
|
||||
// correct for central/test composition roots (no SiteCommunicationActor);
|
||||
// a site role wires the real ClusterClient-based client here so the
|
||||
// SQLite Pending backlog actually drains to central. The forward Ask
|
||||
// reuses NotificationForwardTimeout — the same site→central command
|
||||
// forward bound notifications already use over this transport.
|
||||
ScadaLink.AuditLog.Site.Telemetry.ISiteStreamAuditClient siteAuditClient =
|
||||
new ScadaLink.AuditLog.Site.Telemetry.ClusterClientSiteAuditClient(
|
||||
siteCommActor,
|
||||
_communicationOptions.NotificationForwardTimeout);
|
||||
var siteAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor>();
|
||||
|
||||
|
||||
@@ -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&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;
|
||||
@@ -66,6 +73,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
Receive<InternalMessages.PurgeComplete>(_ => { });
|
||||
Receive<NotificationOutboxQueryRequest>(HandleQuery);
|
||||
Receive<NotificationStatusQuery>(HandleStatusQuery);
|
||||
Receive<NotificationDetailRequest>(HandleDetailRequest);
|
||||
Receive<RetryNotificationRequest>(HandleRetry);
|
||||
Receive<DiscardNotificationRequest>(HandleDiscard);
|
||||
Receive<NotificationKpiRequest>(HandleKpiRequest);
|
||||
@@ -481,6 +489,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
/// parses the notification's id as a Guid; sites generate the id with
|
||||
/// <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,
|
||||
@@ -499,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,
|
||||
@@ -674,6 +694,59 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
DeliveredAt: notification.DeliveredAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a full-detail query for a single notification — backs the report detail
|
||||
/// modal, which needs the Body and resolved recipients that the grid summary omits.
|
||||
/// </summary>
|
||||
private void HandleDetailRequest(NotificationDetailRequest request)
|
||||
{
|
||||
var sender = Sender;
|
||||
|
||||
DetailAsync(request).PipeTo(
|
||||
sender,
|
||||
success: response => response,
|
||||
failure: ex => new NotificationDetailResponse(
|
||||
request.CorrelationId, Success: false,
|
||||
ErrorMessage: ex.GetBaseException().Message, Detail: null));
|
||||
}
|
||||
|
||||
private async Task<NotificationDetailResponse> DetailAsync(NotificationDetailRequest request)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
||||
var notification = await repository.GetByIdAsync(request.NotificationId);
|
||||
|
||||
if (notification is null)
|
||||
{
|
||||
return new NotificationDetailResponse(
|
||||
request.CorrelationId, Success: false,
|
||||
ErrorMessage: "notification not found", Detail: null);
|
||||
}
|
||||
|
||||
var detail = new NotificationDetail(
|
||||
notification.NotificationId,
|
||||
notification.Type.ToString(),
|
||||
notification.ListName,
|
||||
notification.Subject,
|
||||
notification.Body,
|
||||
notification.Status.ToString(),
|
||||
notification.RetryCount,
|
||||
notification.LastError,
|
||||
notification.ResolvedTargets,
|
||||
notification.TypeData,
|
||||
notification.SourceSiteId,
|
||||
notification.SourceInstanceId,
|
||||
notification.SourceScript,
|
||||
notification.SiteEnqueuedAt,
|
||||
notification.CreatedAt,
|
||||
notification.LastAttemptAt,
|
||||
notification.NextAttemptAt,
|
||||
notification.DeliveredAt);
|
||||
|
||||
return new NotificationDetailResponse(
|
||||
request.CorrelationId, Success: true, ErrorMessage: null, detail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a manual retry request. Only a <c>Parked</c> notification can be retried;
|
||||
/// it is reset to <c>Pending</c> with a cleared retry count, next-attempt time, and
|
||||
@@ -878,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.
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<!-- BindConfiguration extension for the SiteCallAuditOptions binding. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -22,6 +24,11 @@
|
||||
project reference is documented here so the actor's scope-per-message
|
||||
GetRequiredService<ISiteCallAuditRepository>() compiles. -->
|
||||
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||
<!-- Task 5 (#22): the central→site Retry/Discard relay routes RetryParkedOperation /
|
||||
DiscardParkedOperation to the owning site via SiteEnvelope + CentralCommunicationActor,
|
||||
the same transport every other central→site command uses. SiteEnvelope is defined
|
||||
in ScadaLink.Communication (no cycle: Communication does not reference SiteCallAudit). -->
|
||||
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,33 +7,34 @@ namespace ScadaLink.SiteCallAudit;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// M3 Bundle C ships the ingest-only minimum surface (the actor itself); the
|
||||
/// full DI surface — reconciliation puller, KPI projector, central→site
|
||||
/// Retry/Discard relay, options + validators — is deferred to a follow-up.
|
||||
/// Binds <see cref="SiteCallAuditOptions"/> (stuck-call detection + KPI
|
||||
/// windowing for the read-side query/KPI handlers). The reconciliation puller
|
||||
/// and central→site Retry/Discard relay are still deferred to later follow-ups.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The repository (<c>ISiteCallAuditRepository</c>) is registered by
|
||||
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
|
||||
/// so callers (the Host on the central node) must also call that. The actor's
|
||||
/// <c>Props</c> are wired up in Host registration (Bundle F); this extension
|
||||
/// is currently a no-op placeholder kept for symmetry with the AuditLog and
|
||||
/// NotificationOutbox composition roots — adding it now means consumers can
|
||||
/// reference the method without re-touching the Host project later.
|
||||
/// <c>Props</c> are wired up in Host registration.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Configuration section bound to <see cref="SiteCallAuditOptions"/>.</summary>
|
||||
public const string OptionsSection = "ScadaLink:SiteCallAudit";
|
||||
|
||||
/// <summary>
|
||||
/// Registers Site Call Audit (#22) services. Currently a no-op
|
||||
/// placeholder — Bundle F will populate this with the actor's Props
|
||||
/// factory + options bindings. The method is exposed now so the Host
|
||||
/// wiring call already exists at the API boundary.
|
||||
/// Registers Site Call Audit (#22) services: the <see cref="SiteCallAuditOptions"/>
|
||||
/// binding consumed by the actor's read-side KPI/query handlers. The actor's
|
||||
/// <c>Props</c> are still constructed inline in Host wiring.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSiteCallAudit(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
// Actor props are constructed in Host wiring (Bundle F). This
|
||||
// extension is a placeholder for future config + DI.
|
||||
|
||||
services.AddOptions<SiteCallAuditOptions>()
|
||||
.BindConfiguration(OptionsSection);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Messages.RemoteQuery;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Communication;
|
||||
|
||||
namespace ScadaLink.SiteCallAudit;
|
||||
|
||||
@@ -18,10 +23,10 @@ namespace ScadaLink.SiteCallAudit;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// M3 ships the minimum surface: ingest only. Reconciliation, KPIs, and
|
||||
/// central→site Retry/Discard relay are deferred (per CLAUDE.md scope
|
||||
/// discipline — Site Call Audit's KPIs and the Retry/Discard relay land in a
|
||||
/// follow-up).
|
||||
/// Query, detail and KPIs (Task 4) and the central→site Retry/Discard relay
|
||||
/// (Task 5 — the relay handlers live in this actor) are implemented; only
|
||||
/// reconciliation remains deferred (per CLAUDE.md scope discipline — it lands
|
||||
/// in a later follow-up).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
|
||||
@@ -42,26 +47,47 @@ namespace ScadaLink.SiteCallAudit;
|
||||
/// </remarks>
|
||||
public class SiteCallAuditActor : ReceiveActor
|
||||
{
|
||||
/// <summary>Maximum page size honoured by a <see cref="SiteCallQueryRequest"/>.</summary>
|
||||
private const int MaxPageSize = 200;
|
||||
|
||||
private readonly IServiceProvider? _serviceProvider;
|
||||
private readonly ISiteCallAuditRepository? _injectedRepository;
|
||||
private readonly SiteCallAuditOptions _options;
|
||||
private readonly ILogger<SiteCallAuditActor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Task 5 (#22): the central→site command transport — the
|
||||
/// <c>CentralCommunicationActor</c>, which owns the per-site
|
||||
/// <c>ClusterClient</c> map and routes a <see cref="SiteEnvelope"/> to the
|
||||
/// owning site. Set via <see cref="RegisterCentralCommunication"/> by the
|
||||
/// Host after both actors exist (this actor is a cluster singleton; the
|
||||
/// transport actor is created separately). Null until registration
|
||||
/// completes — a relay arriving before then is answered with a
|
||||
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome, because there
|
||||
/// is genuinely no route to any site yet.
|
||||
/// </summary>
|
||||
private IActorRef? _centralCommunication;
|
||||
|
||||
/// <summary>
|
||||
/// Test-mode constructor — injects a concrete repository instance whose
|
||||
/// lifetime exceeds the test, so the actor reuses the same instance
|
||||
/// across every message. Used by Bundle C's MSSQL-backed TestKit fixture.
|
||||
/// An optional <paramref name="options"/> lets a test pin the stuck/KPI
|
||||
/// windows; when omitted the production defaults apply.
|
||||
/// </summary>
|
||||
public SiteCallAuditActor(
|
||||
ISiteCallAuditRepository repository,
|
||||
ILogger<SiteCallAuditActor> logger)
|
||||
ILogger<SiteCallAuditActor> logger,
|
||||
SiteCallAuditOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_injectedRepository = repository;
|
||||
_logger = logger;
|
||||
_options = options ?? new SiteCallAuditOptions();
|
||||
|
||||
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
||||
RegisterHandlers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -73,15 +99,42 @@ public class SiteCallAuditActor : ReceiveActor
|
||||
/// </summary>
|
||||
public SiteCallAuditActor(
|
||||
IServiceProvider serviceProvider,
|
||||
SiteCallAuditOptions options,
|
||||
ILogger<SiteCallAuditActor> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_serviceProvider = serviceProvider;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
|
||||
RegisterHandlers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires up the message handlers shared by both constructors: the M3
|
||||
/// ingest path plus the Task 4 read-side (query, detail, global + per-site
|
||||
/// KPI). All read handlers reply to an Ask, so they capture <c>Sender</c>
|
||||
/// before the first await and <c>PipeTo</c> the result back.
|
||||
/// </summary>
|
||||
private void RegisterHandlers()
|
||||
{
|
||||
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
||||
Receive<SiteCallQueryRequest>(HandleQuery);
|
||||
Receive<SiteCallDetailRequest>(HandleDetail);
|
||||
Receive<SiteCallKpiRequest>(HandleKpi);
|
||||
Receive<PerSiteSiteCallKpiRequest>(HandlePerSiteKpi);
|
||||
|
||||
// Task 5 (#22): central→site Retry/Discard relay for parked cached calls.
|
||||
Receive<RegisterCentralCommunication>(msg =>
|
||||
{
|
||||
_centralCommunication = msg.CentralCommunication;
|
||||
_logger.LogInformation("SiteCallAudit registered central→site communication transport");
|
||||
});
|
||||
Receive<RetrySiteCallRequest>(HandleRetrySiteCall);
|
||||
Receive<DiscardSiteCallRequest>(HandleDiscardSiteCall);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -137,4 +190,486 @@ public class SiteCallAuditActor : ReceiveActor
|
||||
scope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task 4: read-side (query / detail / KPI) ──
|
||||
|
||||
/// <summary>
|
||||
/// Handles a paginated, filtered query over the <c>SiteCalls</c> table.
|
||||
/// Builds a <see cref="SiteCallQueryFilter"/> + <see cref="SiteCallPaging"/>
|
||||
/// keyset cursor from the request, runs the query on a scoped repository,
|
||||
/// and pipes the mapped response back to the captured sender. A repository
|
||||
/// fault yields a failure response with an empty list.
|
||||
/// </summary>
|
||||
private void HandleQuery(SiteCallQueryRequest request)
|
||||
{
|
||||
var sender = Sender;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
QueryAsync(request, now).PipeTo(
|
||||
sender,
|
||||
success: response => response,
|
||||
failure: ex => new SiteCallQueryResponse(
|
||||
request.CorrelationId,
|
||||
Success: false,
|
||||
ErrorMessage: ex.GetBaseException().Message,
|
||||
SiteCalls: Array.Empty<SiteCallSummary>(),
|
||||
NextAfterCreatedAtUtc: null,
|
||||
NextAfterId: null));
|
||||
}
|
||||
|
||||
private async Task<SiteCallQueryResponse> QueryAsync(SiteCallQueryRequest request, DateTime now)
|
||||
{
|
||||
var stuckCutoff = now - _options.StuckAgeThreshold;
|
||||
|
||||
var filter = new SiteCallQueryFilter(
|
||||
Channel: NullIfBlank(request.ChannelFilter),
|
||||
SourceSite: NullIfBlank(request.SourceSiteFilter),
|
||||
Status: NullIfBlank(request.StatusFilter),
|
||||
Target: NullIfBlank(request.TargetKeyword),
|
||||
FromUtc: request.FromUtc,
|
||||
ToUtc: request.ToUtc,
|
||||
// StuckOnly is pushed into the repository SQL via StuckCutoffUtc —
|
||||
// TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the
|
||||
// keyset cursor, so the page is always honest (full pages, no empty
|
||||
// pages with a non-null next cursor).
|
||||
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null);
|
||||
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
|
||||
var paging = new SiteCallPaging(
|
||||
PageSize: pageSize,
|
||||
AfterCreatedAtUtc: request.AfterCreatedAtUtc,
|
||||
AfterId: request.AfterId is { } id ? new TrackedOperationId(id) : null);
|
||||
|
||||
var (scope, repository) = ResolveRepository();
|
||||
try
|
||||
{
|
||||
var rows = await repository.QueryAsync(filter, paging).ConfigureAwait(false);
|
||||
|
||||
var summaries = rows
|
||||
.Select(row => ToSummary(row, stuckCutoff))
|
||||
.ToList();
|
||||
|
||||
// The next-page cursor is the last row of the materialised page.
|
||||
var cursorRow = rows.Count > 0 ? rows[^1] : null;
|
||||
|
||||
return new SiteCallQueryResponse(
|
||||
request.CorrelationId,
|
||||
Success: true,
|
||||
ErrorMessage: null,
|
||||
SiteCalls: summaries,
|
||||
NextAfterCreatedAtUtc: cursorRow?.CreatedAtUtc,
|
||||
NextAfterId: cursorRow?.TrackedOperationId.Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
scope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a full-detail query for a single cached call — backs the report
|
||||
/// detail modal. A missing row yields <c>Success=false</c> with a "not
|
||||
/// found" message; a repository fault yields <c>Success=false</c> with the
|
||||
/// fault message.
|
||||
/// </summary>
|
||||
private void HandleDetail(SiteCallDetailRequest request)
|
||||
{
|
||||
var sender = Sender;
|
||||
|
||||
DetailAsync(request).PipeTo(
|
||||
sender,
|
||||
success: response => response,
|
||||
failure: ex => new SiteCallDetailResponse(
|
||||
request.CorrelationId,
|
||||
Success: false,
|
||||
ErrorMessage: ex.GetBaseException().Message,
|
||||
Detail: null));
|
||||
}
|
||||
|
||||
private async Task<SiteCallDetailResponse> DetailAsync(SiteCallDetailRequest request)
|
||||
{
|
||||
var (scope, repository) = ResolveRepository();
|
||||
try
|
||||
{
|
||||
var row = await repository
|
||||
.GetAsync(new TrackedOperationId(request.TrackedOperationId))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return new SiteCallDetailResponse(
|
||||
request.CorrelationId,
|
||||
Success: false,
|
||||
ErrorMessage: "site call not found",
|
||||
Detail: null);
|
||||
}
|
||||
|
||||
return new SiteCallDetailResponse(
|
||||
request.CorrelationId,
|
||||
Success: true,
|
||||
ErrorMessage: null,
|
||||
Detail: ToDetail(row));
|
||||
}
|
||||
finally
|
||||
{
|
||||
scope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a global KPI snapshot request, deriving the stuck cutoff from
|
||||
/// <see cref="SiteCallAuditOptions.StuckAgeThreshold"/> and the
|
||||
/// failed/delivered interval bound from <see cref="SiteCallAuditOptions.KpiInterval"/>.
|
||||
/// </summary>
|
||||
private void HandleKpi(SiteCallKpiRequest request)
|
||||
{
|
||||
var sender = Sender;
|
||||
var now = DateTime.UtcNow;
|
||||
var stuckCutoff = now - _options.StuckAgeThreshold;
|
||||
var intervalSince = now - _options.KpiInterval;
|
||||
|
||||
KpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
|
||||
sender,
|
||||
success: response => response,
|
||||
failure: ex => new SiteCallKpiResponse(
|
||||
request.CorrelationId,
|
||||
Success: false,
|
||||
ErrorMessage: ex.GetBaseException().Message,
|
||||
BufferedCount: 0,
|
||||
ParkedCount: 0,
|
||||
FailedLastInterval: 0,
|
||||
DeliveredLastInterval: 0,
|
||||
OldestPendingAge: null,
|
||||
StuckCount: 0));
|
||||
}
|
||||
|
||||
private async Task<SiteCallKpiResponse> KpiAsync(
|
||||
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
|
||||
{
|
||||
var (scope, repository) = ResolveRepository();
|
||||
try
|
||||
{
|
||||
var snapshot = await repository
|
||||
.ComputeKpisAsync(stuckCutoff, intervalSince)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SiteCallKpiResponse(
|
||||
correlationId,
|
||||
Success: true,
|
||||
ErrorMessage: null,
|
||||
snapshot.BufferedCount,
|
||||
snapshot.ParkedCount,
|
||||
snapshot.FailedLastInterval,
|
||||
snapshot.DeliveredLastInterval,
|
||||
snapshot.OldestPendingAge,
|
||||
snapshot.StuckCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
scope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a per-source-site KPI request, using the same stuck cutoff and
|
||||
/// interval bound as <see cref="HandleKpi"/>.
|
||||
/// </summary>
|
||||
private void HandlePerSiteKpi(PerSiteSiteCallKpiRequest request)
|
||||
{
|
||||
var sender = Sender;
|
||||
var now = DateTime.UtcNow;
|
||||
var stuckCutoff = now - _options.StuckAgeThreshold;
|
||||
var intervalSince = now - _options.KpiInterval;
|
||||
|
||||
PerSiteKpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
|
||||
sender,
|
||||
success: response => response,
|
||||
failure: ex => new PerSiteSiteCallKpiResponse(
|
||||
request.CorrelationId,
|
||||
Success: false,
|
||||
ErrorMessage: ex.GetBaseException().Message,
|
||||
Sites: Array.Empty<SiteCallSiteKpiSnapshot>()));
|
||||
}
|
||||
|
||||
private async Task<PerSiteSiteCallKpiResponse> PerSiteKpiAsync(
|
||||
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
|
||||
{
|
||||
var (scope, repository) = ResolveRepository();
|
||||
try
|
||||
{
|
||||
var sites = await repository
|
||||
.ComputePerSiteKpisAsync(stuckCutoff, intervalSince)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new PerSiteSiteCallKpiResponse(
|
||||
correlationId, Success: true, ErrorMessage: null, sites);
|
||||
}
|
||||
finally
|
||||
{
|
||||
scope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task 5: central→site Retry/Discard relay ──
|
||||
|
||||
/// <summary>
|
||||
/// Relays an operator Retry of a parked cached call to its owning site. The
|
||||
/// site is the source of truth — this handler NEVER writes the central
|
||||
/// <c>SiteCalls</c> mirror row. It wraps a <see cref="RetryParkedOperation"/>
|
||||
/// in a <see cref="SiteEnvelope"/> addressed to <c>SourceSite</c>, Asks the
|
||||
/// <c>CentralCommunicationActor</c> (which routes it over the per-site
|
||||
/// <c>ClusterClient</c>), and maps the site's
|
||||
/// <see cref="ParkedOperationActionAck"/> — or an Ask timeout — onto a
|
||||
/// <see cref="RetrySiteCallResponse"/>. A timeout / no-route is reported as
|
||||
/// the distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome,
|
||||
/// not a generic failure, so the Central UI can tell "site offline" from
|
||||
/// "operation failed".
|
||||
/// </summary>
|
||||
private void HandleRetrySiteCall(RetrySiteCallRequest request)
|
||||
{
|
||||
var sender = Sender;
|
||||
|
||||
if (_centralCommunication is null)
|
||||
{
|
||||
// No transport registered yet — there is genuinely no route to any
|
||||
// site, so the only honest answer is unreachable.
|
||||
_logger.LogWarning(
|
||||
"RetrySiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
|
||||
+ "central→site transport was registered; reporting site unreachable",
|
||||
request.TrackedOperationId, request.SourceSite);
|
||||
sender.Tell(UnreachableRetry(request.CorrelationId));
|
||||
return;
|
||||
}
|
||||
|
||||
var relay = new RetryParkedOperation(
|
||||
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
|
||||
var envelope = new SiteEnvelope(request.SourceSite, relay);
|
||||
|
||||
_centralCommunication.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
|
||||
.PipeTo(
|
||||
sender,
|
||||
success: ack => MapRetryResponse(request.CorrelationId, ack),
|
||||
failure: ex => MapRetryFailure(request.CorrelationId, request.SourceSite, ex));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Relays an operator Discard of a parked cached call to its owning site.
|
||||
/// Mirrors <see cref="HandleRetrySiteCall"/> — see that method for the
|
||||
/// source-of-truth and site-unreachable rationale.
|
||||
/// </summary>
|
||||
private void HandleDiscardSiteCall(DiscardSiteCallRequest request)
|
||||
{
|
||||
var sender = Sender;
|
||||
|
||||
if (_centralCommunication is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiscardSiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
|
||||
+ "central→site transport was registered; reporting site unreachable",
|
||||
request.TrackedOperationId, request.SourceSite);
|
||||
sender.Tell(UnreachableDiscard(request.CorrelationId));
|
||||
return;
|
||||
}
|
||||
|
||||
var relay = new DiscardParkedOperation(
|
||||
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
|
||||
var envelope = new SiteEnvelope(request.SourceSite, relay);
|
||||
|
||||
_centralCommunication.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
|
||||
.PipeTo(
|
||||
sender,
|
||||
success: ack => MapDiscardResponse(request.CorrelationId, ack),
|
||||
failure: ex => MapDiscardFailure(request.CorrelationId, request.SourceSite, ex));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the site's <see cref="ParkedOperationActionAck"/> for a Retry onto a
|
||||
/// <see cref="RetrySiteCallResponse"/>: an applied action is
|
||||
/// <see cref="SiteCallRelayOutcome.Applied"/>; a clean no-op
|
||||
/// (<c>Applied=false</c>, no error) is <see cref="SiteCallRelayOutcome.NotParked"/>;
|
||||
/// an ack carrying an error is <see cref="SiteCallRelayOutcome.OperationFailed"/>
|
||||
/// — in every case the site WAS reached.
|
||||
/// </summary>
|
||||
private static RetrySiteCallResponse MapRetryResponse(string correlationId, ParkedOperationActionAck ack)
|
||||
{
|
||||
var outcome = ClassifyAck(ack);
|
||||
return new RetrySiteCallResponse(
|
||||
correlationId,
|
||||
outcome,
|
||||
Success: outcome == SiteCallRelayOutcome.Applied,
|
||||
SiteReachable: true,
|
||||
ErrorMessage: AckErrorMessage(outcome, ack));
|
||||
}
|
||||
|
||||
private static DiscardSiteCallResponse MapDiscardResponse(string correlationId, ParkedOperationActionAck ack)
|
||||
{
|
||||
var outcome = ClassifyAck(ack);
|
||||
return new DiscardSiteCallResponse(
|
||||
correlationId,
|
||||
outcome,
|
||||
Success: outcome == SiteCallRelayOutcome.Applied,
|
||||
SiteReachable: true,
|
||||
ErrorMessage: AckErrorMessage(outcome, ack));
|
||||
}
|
||||
|
||||
private RetrySiteCallResponse MapRetryFailure(string correlationId, string sourceSite, Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Retry relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
|
||||
return UnreachableRetry(correlationId);
|
||||
}
|
||||
|
||||
private DiscardSiteCallResponse MapDiscardFailure(string correlationId, string sourceSite, Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Discard relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
|
||||
return UnreachableDiscard(correlationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a site ack: <c>Applied=true</c> → applied; <c>Applied=false</c>
|
||||
/// with no error → the site definitively had nothing parked; <c>Applied=false</c>
|
||||
/// with an error → the site could not apply the action.
|
||||
/// </summary>
|
||||
private static SiteCallRelayOutcome ClassifyAck(ParkedOperationActionAck ack)
|
||||
{
|
||||
if (ack.Applied)
|
||||
{
|
||||
return SiteCallRelayOutcome.Applied;
|
||||
}
|
||||
|
||||
return ack.ErrorMessage is null
|
||||
? SiteCallRelayOutcome.NotParked
|
||||
: SiteCallRelayOutcome.OperationFailed;
|
||||
}
|
||||
|
||||
private static string? AckErrorMessage(SiteCallRelayOutcome outcome, ParkedOperationActionAck ack)
|
||||
{
|
||||
return outcome switch
|
||||
{
|
||||
SiteCallRelayOutcome.Applied => null,
|
||||
SiteCallRelayOutcome.NotParked =>
|
||||
"The operation is no longer parked at the site (already delivered, discarded, or retrying).",
|
||||
SiteCallRelayOutcome.OperationFailed => ack.ErrorMessage,
|
||||
// SiteUnreachable is never produced from a ParkedOperationActionAck —
|
||||
// unreachable responses are built by UnreachableRetry/UnreachableDiscard
|
||||
// before any ack is classified, so this arm is unreachable by construction.
|
||||
SiteCallRelayOutcome.SiteUnreachable => ack.ErrorMessage,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(outcome), outcome, "unknown SiteCallRelayOutcome"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Shared "site unreachable" detail text for both relay directions.</summary>
|
||||
private const string SiteUnreachableMessage =
|
||||
"The owning site is unreachable; the action was not applied. Retry when the site is back online.";
|
||||
|
||||
private static RetrySiteCallResponse UnreachableRetry(string correlationId)
|
||||
{
|
||||
return new RetrySiteCallResponse(
|
||||
correlationId,
|
||||
SiteCallRelayOutcome.SiteUnreachable,
|
||||
Success: false,
|
||||
SiteReachable: false,
|
||||
ErrorMessage: SiteUnreachableMessage);
|
||||
}
|
||||
|
||||
private static DiscardSiteCallResponse UnreachableDiscard(string correlationId)
|
||||
{
|
||||
return new DiscardSiteCallResponse(
|
||||
correlationId,
|
||||
SiteCallRelayOutcome.SiteUnreachable,
|
||||
Success: false,
|
||||
SiteReachable: false,
|
||||
ErrorMessage: SiteUnreachableMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="ISiteCallAuditRepository"/> for one read message.
|
||||
/// In test mode the injected instance is returned with a null scope; in
|
||||
/// production a fresh DI scope is created and returned so the caller can
|
||||
/// dispose it once the read completes — the same scope-per-message pattern
|
||||
/// as <see cref="OnUpsertAsync"/>.
|
||||
/// </summary>
|
||||
private (IServiceScope? Scope, ISiteCallAuditRepository Repository) ResolveRepository()
|
||||
{
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
return (null, _injectedRepository);
|
||||
}
|
||||
|
||||
var scope = _serviceProvider!.CreateScope();
|
||||
return (scope, scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cached call counts as stuck when it is still non-terminal and was
|
||||
/// created before <paramref name="stuckCutoff"/>. Non-terminal is keyed off
|
||||
/// <see cref="SiteCall.TerminalAtUtc"/> being <c>null</c> — the
|
||||
/// <c>SiteCalls</c> operational mirror stores <c>AuditStatus</c>-derived
|
||||
/// status strings (<c>Attempted</c>/<c>Delivered</c>/<c>Parked</c>/...), not
|
||||
/// the tracking-lifecycle <c>Pending</c>/<c>Retrying</c> names the spec's
|
||||
/// KPI section uses, so there is no status string that means "buffered".
|
||||
/// <c>TerminalAtUtc</c> is the entity's own active/terminal discriminator
|
||||
/// and is consistent with the repository KPI counts and
|
||||
/// <c>PurgeTerminalAsync</c>.
|
||||
/// </summary>
|
||||
private static bool IsStuck(SiteCall row, DateTime stuckCutoff)
|
||||
{
|
||||
return row.TerminalAtUtc is null && row.CreatedAtUtc < stuckCutoff;
|
||||
}
|
||||
|
||||
private static SiteCallSummary ToSummary(SiteCall row, DateTime stuckCutoff)
|
||||
{
|
||||
return new SiteCallSummary(
|
||||
TrackedOperationId: row.TrackedOperationId.Value,
|
||||
SourceSite: row.SourceSite,
|
||||
Channel: row.Channel,
|
||||
Target: row.Target,
|
||||
Status: row.Status,
|
||||
RetryCount: row.RetryCount,
|
||||
LastError: row.LastError,
|
||||
HttpStatus: row.HttpStatus,
|
||||
CreatedAtUtc: row.CreatedAtUtc,
|
||||
UpdatedAtUtc: row.UpdatedAtUtc,
|
||||
TerminalAtUtc: row.TerminalAtUtc,
|
||||
IsStuck: IsStuck(row, stuckCutoff));
|
||||
}
|
||||
|
||||
private static SiteCallDetail ToDetail(SiteCall row)
|
||||
{
|
||||
return new SiteCallDetail(
|
||||
TrackedOperationId: row.TrackedOperationId.Value,
|
||||
SourceSite: row.SourceSite,
|
||||
Channel: row.Channel,
|
||||
Target: row.Target,
|
||||
Status: row.Status,
|
||||
RetryCount: row.RetryCount,
|
||||
LastError: row.LastError,
|
||||
HttpStatus: row.HttpStatus,
|
||||
CreatedAtUtc: row.CreatedAtUtc,
|
||||
UpdatedAtUtc: row.UpdatedAtUtc,
|
||||
TerminalAtUtc: row.TerminalAtUtc,
|
||||
IngestedAtUtc: row.IngestedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Treats an empty/whitespace filter string as "no constraint" — the
|
||||
/// repository's <see cref="SiteCallQueryFilter"/> interprets <c>null</c> as
|
||||
/// a no-op predicate, so a blank UI filter must collapse to <c>null</c>.
|
||||
/// </summary>
|
||||
private static string? NullIfBlank(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the central→site command transport (the <c>CentralCommunicationActor</c>)
|
||||
/// with the <see cref="SiteCallAuditActor"/> so it can relay Retry/Discard
|
||||
/// actions on parked cached calls to their owning sites. Sent by the Host after
|
||||
/// both actors exist. Lives here (not in Commons) because it carries an
|
||||
/// <see cref="IActorRef"/> and <c>ScadaLink.Commons</c> has no Akka reference —
|
||||
/// the same rationale as <c>RegisterAuditIngest</c>.
|
||||
/// </summary>
|
||||
public sealed record RegisterCentralCommunication(IActorRef CentralCommunication);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ScadaLink.SiteCallAudit;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Site Call Audit (#22) read-side: stuck-call
|
||||
/// detection and KPI windowing. Mirrors the KPI-relevant subset of
|
||||
/// <c>NotificationOutboxOptions</c> — the reconciliation, purge and dispatch
|
||||
/// cadence options the Notification Outbox carries are not part of the Site
|
||||
/// Call Audit read-side backend and are deliberately omitted here.
|
||||
/// </summary>
|
||||
public class SiteCallAuditOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Age past which a non-terminal cached call (<c>Pending</c>/<c>Retrying</c>)
|
||||
/// is considered stuck. Display-only — surfaced as the Stuck KPI and a row
|
||||
/// badge, with no escalation. Default 10 minutes, matching
|
||||
/// <c>NotificationOutboxOptions.StuckAgeThreshold</c>.
|
||||
/// </summary>
|
||||
public TimeSpan StuckAgeThreshold { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Trailing window used to compute the delivered- and failed-last-interval
|
||||
/// throughput KPIs. Default 1 minute, matching
|
||||
/// <c>NotificationOutboxOptions.DeliveredKpiWindow</c>.
|
||||
/// </summary>
|
||||
public TimeSpan KpiInterval { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Task 5 (#22): Ask timeout for the central→site Retry/Discard relay. When
|
||||
/// the owning site does not ack a <c>RetryParkedOperation</c> /
|
||||
/// <c>DiscardParkedOperation</c> within this window — site offline, no
|
||||
/// ClusterClient route, or central buffering deliberately absent — the relay
|
||||
/// reports a <c>SiteUnreachable</c> outcome. Default 10 seconds: long enough
|
||||
/// to absorb a healthy cross-cluster round-trip, short enough that an
|
||||
/// operator clicking Retry on an offline site gets a fast, honest answer.
|
||||
/// <para>
|
||||
/// <b>Ordering invariant:</b> <c>RelayTimeout</c> must stay below
|
||||
/// <c>CommunicationOptions.QueryTimeout</c> (default 30s), the timeout the
|
||||
/// outer <c>CommunicationService.RetrySiteCallAsync</c>/<c>DiscardSiteCallAsync</c>
|
||||
/// Ask of the <c>SiteCallAuditActor</c> uses. The outer Ask must outlive this
|
||||
/// inner site relay Ask so the inner relay times out first and yields the
|
||||
/// distinct <c>SiteUnreachable</c> outcome; if the outer Ask expired first,
|
||||
/// that outcome would be lost to a generic Ask-timeout exception. The
|
||||
/// defaults (10s < 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,
|
||||
|
||||
@@ -24,6 +24,13 @@ public class ParkedMessageHandlerActor : ReceiveActor
|
||||
Receive<ParkedMessageQueryRequest>(HandleQuery);
|
||||
Receive<ParkedMessageRetryRequest>(HandleRetry);
|
||||
Receive<ParkedMessageDiscardRequest>(HandleDiscard);
|
||||
|
||||
// Task 5 (#22): central→site Retry/Discard relay for parked cached
|
||||
// operations. The cached call's S&F buffer message id is the
|
||||
// TrackedOperationId, so these reuse the same parked-message primitive
|
||||
// as HandleRetry/HandleDiscard, keyed off the tracked id.
|
||||
Receive<RetryParkedOperation>(HandleRetryParkedOperation);
|
||||
Receive<DiscardParkedOperation>(HandleDiscardParkedOperation);
|
||||
}
|
||||
|
||||
private void HandleQuery(ParkedMessageQueryRequest msg)
|
||||
@@ -90,6 +97,46 @@ public class ParkedMessageHandlerActor : ReceiveActor
|
||||
msg.CorrelationId, false, ex.GetBaseException().Message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 5 (#22): executes a central-relayed Retry of a parked cached call.
|
||||
/// The tracked id is the S&F buffer message id, so this reuses
|
||||
/// <see cref="StoreAndForwardService.RetryParkedMessageAsync"/> — which only
|
||||
/// touches rows that are actually <c>Parked</c> (a non-parked or unknown
|
||||
/// operation yields <c>false</c>, a safe no-op). Central never mutates the
|
||||
/// central <c>SiteCalls</c> mirror; the reset row's corrected state flows
|
||||
/// back via the normal cached-call telemetry path.
|
||||
/// </summary>
|
||||
private void HandleRetryParkedOperation(RetryParkedOperation msg)
|
||||
{
|
||||
var sender = Sender;
|
||||
|
||||
_service.RetryParkedMessageAsync(msg.TrackedOperationId.ToString())
|
||||
.PipeTo(
|
||||
sender,
|
||||
success: applied => new ParkedOperationActionAck(
|
||||
msg.CorrelationId, applied, ErrorMessage: null),
|
||||
failure: ex => new ParkedOperationActionAck(
|
||||
msg.CorrelationId, Applied: false, ex.GetBaseException().Message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 5 (#22): executes a central-relayed Discard of a parked cached call.
|
||||
/// Mirrors <see cref="HandleRetryParkedOperation"/>; Discard removes the
|
||||
/// parked S&F buffer row (only when it is actually <c>Parked</c>).
|
||||
/// </summary>
|
||||
private void HandleDiscardParkedOperation(DiscardParkedOperation msg)
|
||||
{
|
||||
var sender = Sender;
|
||||
|
||||
_service.DiscardParkedMessageAsync(msg.TrackedOperationId.ToString())
|
||||
.PipeTo(
|
||||
sender,
|
||||
success: applied => new ParkedOperationActionAck(
|
||||
msg.CorrelationId, applied, ErrorMessage: null),
|
||||
failure: ex => new ParkedOperationActionAck(
|
||||
msg.CorrelationId, Applied: false, ex.GetBaseException().Message));
|
||||
}
|
||||
|
||||
private static string ExtractMethodName(string payloadJson, Commons.Types.Enums.StoreAndForwardCategory category)
|
||||
{
|
||||
if (string.IsNullOrEmpty(payloadJson))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +356,12 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
_inner.QueryAsync(filter, paging, ct);
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -387,5 +393,11 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
||||
_inner.QueryAsync(filter, paging, ct);
|
||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
|
||||
using ScadaLink.AuditLog.Telemetry;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Messages.Integration;
|
||||
using ScadaLink.Commons.Types;
|
||||
@@ -55,7 +54,7 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
||||
{
|
||||
var dto = new CachedTelemetryPacket
|
||||
{
|
||||
AuditEvent = AuditEventMapper.ToDto(new AuditEvent
|
||||
AuditEvent = AuditEventDtoMapper.ToDto(new AuditEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
OccurredAtUtc = nowUtc,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
using ScadaLink.AuditLog.Site.Telemetry;
|
||||
using ScadaLink.AuditLog.Telemetry;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.Integration;
|
||||
using ScadaLink.Commons.Types;
|
||||
@@ -88,7 +87,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
|
||||
{
|
||||
return new CachedTelemetryPacket
|
||||
{
|
||||
AuditEvent = AuditEventMapper.ToDto(telemetry.Audit),
|
||||
AuditEvent = AuditEventDtoMapper.ToDto(telemetry.Audit),
|
||||
Operational = ToOperationalDto(telemetry.Operational),
|
||||
};
|
||||
}
|
||||
|
||||
+6
-33
@@ -1,9 +1,7 @@
|
||||
using Akka.Actor;
|
||||
using ScadaLink.AuditLog.Site.Telemetry;
|
||||
using ScadaLink.AuditLog.Telemetry;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Messages.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
|
||||
namespace ScadaLink.AuditLog.Tests.Integration.Infrastructure;
|
||||
@@ -88,7 +86,7 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
var events = new List<AuditEvent>(batch.Events.Count);
|
||||
foreach (var dto in batch.Events)
|
||||
{
|
||||
events.Add(AuditEventMapper.FromDto(dto));
|
||||
events.Add(AuditEventDtoMapper.FromDto(dto));
|
||||
}
|
||||
|
||||
// Ask the central actor; the reply carries the accepted EventIds.
|
||||
@@ -114,10 +112,9 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
/// back into the proto ack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses the shared <see cref="AuditEventMapper.FromDto"/> for the audit half;
|
||||
/// the SiteCall DTO is decoded inline because the AuditLog mapper does not
|
||||
/// (and should not) know about <see cref="SiteCallOperationalDto"/> — the
|
||||
/// production gRPC server (Bundle D) uses the same inline shape.
|
||||
/// Uses the shared <see cref="AuditEventDtoMapper.FromDto"/> for the audit half
|
||||
/// and <see cref="SiteCallDtoMapper.FromDto"/> for the SiteCall half — the same
|
||||
/// canonical mappers the production <c>SiteStreamGrpcServer</c> uses.
|
||||
/// </remarks>
|
||||
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
||||
{
|
||||
@@ -132,8 +129,8 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
|
||||
foreach (var packet in batch.Packets)
|
||||
{
|
||||
var audit = AuditEventMapper.FromDto(packet.AuditEvent);
|
||||
var siteCall = MapSiteCallFromDto(packet.Operational);
|
||||
var audit = AuditEventDtoMapper.FromDto(packet.AuditEvent);
|
||||
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
|
||||
entries.Add(new CachedTelemetryEntry(audit, siteCall));
|
||||
}
|
||||
|
||||
@@ -150,28 +147,4 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>SiteStreamGrpcServer.MapSiteCallFromDto</c> — keep the two in
|
||||
/// sync. The placeholder <see cref="SiteCall.IngestedAtUtc"/> stamped here
|
||||
/// is overwritten by the central ingest actor inside the dual-write
|
||||
/// transaction, so the value sent on the wire is informational only.
|
||||
/// </summary>
|
||||
private static SiteCall MapSiteCallFromDto(SiteCallOperationalDto dto) => new()
|
||||
{
|
||||
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
|
||||
Channel = dto.Channel,
|
||||
Target = dto.Target,
|
||||
SourceSite = dto.SourceSite,
|
||||
Status = dto.Status,
|
||||
RetryCount = dto.RetryCount,
|
||||
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
|
||||
HttpStatus = dto.HttpStatus,
|
||||
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
TerminalAtUtc = dto.TerminalAtUtc is null
|
||||
? null
|
||||
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||
IngestedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user