diff --git a/docs/plans/2026-05-20-audit-log-code-roadmap.md b/docs/plans/2026-05-20-audit-log-code-roadmap.md index 9483a2b..488ac25 100644 --- a/docs/plans/2026-05-20-audit-log-code-roadmap.md +++ b/docs/plans/2026-05-20-audit-log-code-roadmap.md @@ -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.** diff --git a/docs/plans/2026-05-21-audit-log-followups.md b/docs/plans/2026-05-21-audit-log-followups.md new file mode 100644 index 0000000..37f57fb --- /dev/null +++ b/docs/plans/2026-05-21-audit-log-followups.md @@ -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 ` — 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` + `Receive` 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)`, `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` (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, 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? 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 ``; 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. diff --git a/docs/plans/2026-05-21-audit-log-followups.md.tasks.json b/docs/plans/2026-05-21-audit-log-followups.md.tasks.json new file mode 100644 index 0000000..42e9d4e --- /dev/null +++ b/docs/plans/2026-05-21-audit-log-followups.md.tasks.json @@ -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" +} diff --git a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs index 626859f..e0d9e65 100644 --- a/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs @@ -121,11 +121,14 @@ public static class ServiceCollectionExtensions logger: sp.GetRequiredService>(), filter: sp.GetRequiredService())); - // 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(); // M3 Bundle F: site-side dual emitter for cached-call lifecycle diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index bf5cb8b..3bce65c 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -351,6 +351,54 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable } } + /// + /// Returns up to rows in + /// , oldest + /// first, with + /// as the deterministic tiebreaker. The + /// -specific counterpart of + /// ; used by tests to assert a row reached the + /// state specifically (unlike + /// , which also returns + /// rows). + /// + public Task> 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 + 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(Math.Min(limit, 256)); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + rows.Add(MapRow(reader)); + } + + return Task.FromResult>(rows); + } + } + /// /// Flips the supplied EventIds from to /// in a single UPDATE. Non-existent diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs index 7f45453..c2cea5b 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallTelemetryForwarder.cs @@ -34,15 +34,17 @@ namespace ScadaLink.AuditLog.Site.Telemetry; /// returns normally. /// /// -/// Wire push deferred to M6. M3 keeps this forwarder synchronous -/// against the local stores: there is no site→central gRPC channel yet, so -/// the RPC -/// is registered on the interface (Bundle E1) but the production binding -/// remains NoOpSiteStreamAuditClient. Once M6 wires a real client the -/// drain pattern from SiteAuditTelemetryActor can be reused — the -/// AuditEvent rows already live in SQLite tagged -/// , so a single drain loop sweeps -/// both M2 and M3 emissions. +/// Local-write only — the wire push is the drain actor's job. 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: ClusterClientSiteAuditClient is the production binding of +/// on site roles (with +/// NoOpSiteStreamAuditClient retained only for central/test composition +/// roots). The push happens out-of-band: +/// sweeps the AuditEvent rows this forwarder wrote — they live in SQLite +/// tagged — 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. /// /// public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs new file mode 100644 index 0000000..492065e --- /dev/null +++ b/src/ScadaLink.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs @@ -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; + +/// +/// Production binding for site composition +/// roots: pushes audit telemetry to central over Akka ClusterClient via +/// the site's SiteCommunicationActor. The actor forwards the command to +/// /user/central-communication and the central +/// CentralCommunicationActor Asks the AuditLogIngestActor proxy — +/// the same command/control transport notifications already use. Wired by the +/// Host for site roles; central and test composition roots keep the +/// DI default (they have no +/// SiteCommunicationActor). +/// +/// +/// +/// Throw-on-failure contract. An Ask timeout or a faulted reply +/// () propagates as a thrown exception out of the +/// Ingest*Async methods — it is NOT caught and turned into an empty ack. +/// The drain loop treats a thrown +/// exception as transient and leaves the rows Pending 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. +/// +/// +/// The batches arrive as proto DTOs ( / +/// ) because the +/// builds them with +/// . This client converts them back into +/// the / entities the Akka +/// command messages carry — the same DTO→entity translation the +/// SiteStreamGrpcServer performs for the gRPC reconciliation path. +/// +/// +public sealed class ClusterClientSiteAuditClient : ISiteStreamAuditClient +{ + private readonly IActorRef _siteCommunicationActor; + private readonly TimeSpan _askTimeout; + + /// + /// The site's SiteCommunicationActor — it forwards the ingest command + /// over the registered central ClusterClient and routes the reply back to + /// this client's Ask. + /// + /// + /// Ask timeout for the round-trip to central. On expiry the Ask throws + /// , which the drain loop treats + /// as transient (rows stay Pending). + /// + public ClusterClientSiteAuditClient(IActorRef siteCommunicationActor, TimeSpan askTimeout) + { + ArgumentNullException.ThrowIfNull(siteCommunicationActor); + _siteCommunicationActor = siteCommunicationActor; + _askTimeout = askTimeout; + } + + /// + public async Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(batch); + + var events = new List(batch.Events.Count); + foreach (var dto in batch.Events) + { + events.Add(AuditEventDtoMapper.FromDto(dto)); + } + + // Ask 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(new IngestAuditEventsCommand(events), _askTimeout, ct) + .ConfigureAwait(false); + + return ToAck(reply.AcceptedEventIds); + } + + /// + public async Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(batch); + + var entries = new List(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(new IngestCachedTelemetryCommand(entries), _askTimeout, ct) + .ConfigureAwait(false); + + return ToAck(reply.AcceptedEventIds); + } + + private static IngestAck ToAck(IReadOnlyList acceptedEventIds) + { + var ack = new IngestAck(); + foreach (var id in acceptedEventIds) + { + ack.AcceptedEventIds.Add(id.ToString()); + } + return ack; + } +} diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs index 6314bba..b6b27f5 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/ISiteStreamAuditClient.cs @@ -3,40 +3,40 @@ using ScadaLink.Communication.Grpc; namespace ScadaLink.AuditLog.Site.Telemetry; /// -/// Mockable abstraction over the central site-stream gRPC client surface that -/// uses to push -/// payloads. The production implementation (added in Bundle E host wiring) -/// wraps the auto-generated SiteStreamService.SiteStreamServiceClient; -/// 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 +/// uses to forward +/// payloads. The production implementation is +/// — a ClusterClient-based client, +/// wired in the Host for site roles, that forwards batches to central via the +/// site's SiteCommunicationActor. Unit tests substitute via NSubstitute +/// against this interface so the actor never needs a live transport. /// public interface ISiteStreamAuditClient { /// - /// Pushes to the central IngestAuditEvents - /// RPC. The returned carries the - /// accepted_event_ids the actor will flip to + /// Forwards to the central audit-ingest path. The + /// returned carries the accepted_event_ids + /// the actor will flip to /// /// in the site SQLite queue. /// Task IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct); /// - /// Pushes the combined (Audit Log #23 / M3) - /// to the central IngestCachedTelemetry RPC. Each packet carries both - /// the audit row and the operational SiteCalls upsert; central writes - /// both in a single MS SQL transaction. Returns the same - /// shape as so - /// the M3 site-side forwarder can flip the underlying audit rows to + /// Forwards the combined (Audit Log #23) + /// to the central cached-telemetry ingest path. Each packet carries both the + /// audit row and the operational SiteCalls upsert; central writes both + /// in a single MS SQL transaction. Returns the same + /// shape as so the site-side forwarder + /// can flip the underlying audit rows to /// /// once central has acknowledged them. /// /// - /// The production gRPC-backed implementation lands in M6 (no site→central - /// gRPC channel exists today); until then the default - /// binding returns an empty ack and - /// integration tests substitute a direct-actor client that routes the batch - /// straight into the in-process AuditLogIngestActor. + /// The production forwards over + /// the ClusterClient transport; the + /// DI default (used by central and test composition roots) returns an empty + /// ack so no rows are flipped. /// Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct); } diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs index b83a215..2bf786d 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/NoOpSiteStreamAuditClient.cs @@ -5,20 +5,18 @@ namespace ScadaLink.AuditLog.Site.Telemetry; /// /// Default registered by /// . -/// 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 +/// SiteCommunicationActor — central and test roots. Site roles override +/// it in the Host with the ClusterClient-based +/// , which actually forwards audit +/// telemetry to central. /// /// /// /// Returns an empty so the /// doesn't flip any rows to -/// Forwarded when this NoOp is in effect — Bundle H's integration test -/// substitutes a stub client that routes directly to the central -/// AuditLogIngestActor in-process. Production wiring (M6) will replace -/// this binding with a real client. +/// Forwarded when this NoOp is in effect — rows stay Pending +/// until a real client (or a test stub) takes over. /// /// /// 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 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); } } diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs b/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs index 724e1d1..e903e0a 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/SiteAuditTelemetryActor.cs @@ -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; } diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs index b411b91..7120ffc 100644 --- a/src/ScadaLink.CLI/Commands/AuditCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -26,16 +26,36 @@ public static class AuditCommands { var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" }; var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" }; - var channelOption = new Option("--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("--channel") + { + Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable", + AllowMultipleArgumentsPerToken = true, + }; channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound"); - var kindOption = new Option("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve)" }; + var kindOption = new Option("--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("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped)" }; + var statusOption = new Option("--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("--site") { Description = "Filter by source site ID" }; + var siteOption = new Option("--site") + { + Description = "Filter by source site ID; repeatable", + AllowMultipleArgumentsPerToken = true, + }; var targetOption = new Option("--target") { Description = "Filter by target (external system, DB connection, notification list)" }; var actorOption = new Option("--actor") { Description = "Filter by actor" }; var correlationIdOption = new Option("--correlation-id") { Description = "Filter by correlation ID" }; @@ -74,10 +94,10 @@ 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(), + Kind = result.GetValue(kindOption) ?? Array.Empty(), + Status = result.GetValue(statusOption) ?? Array.Empty(), + Site = result.GetValue(siteOption) ?? Array.Empty(), Target = result.GetValue(targetOption), Actor = result.GetValue(actorOption), CorrelationId = result.GetValue(correlationIdOption), @@ -108,10 +128,36 @@ public static class AuditCommands var formatExportOption = new Option("--format") { Description = "Export format", Required = true }; formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet"); var outputOption = new Option("--output") { Description = "Destination file path", Required = true }; - var channelOption = new Option("--channel") { Description = "Filter by channel" }; - var kindOption = new Option("--kind") { Description = "Filter by event kind" }; - var statusOption = new Option("--status") { Description = "Filter by status" }; - var siteOption = new Option("--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("--channel") + { + Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable", + AllowMultipleArgumentsPerToken = true, + }; + channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound"); + var kindOption = new Option("--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("--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("--site") + { + Description = "Filter by source site ID; repeatable", + AllowMultipleArgumentsPerToken = true, + }; var targetOption = new Option("--target") { Description = "Filter by target" }; var actorOption = new Option("--actor") { Description = "Filter by actor" }; @@ -142,10 +188,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(), + Kind = result.GetValue(kindOption) ?? Array.Empty(), + Status = result.GetValue(statusOption) ?? Array.Empty(), + Site = result.GetValue(siteOption) ?? Array.Empty(), Target = result.GetValue(targetOption), Actor = result.GetValue(actorOption), }; diff --git a/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs index 01d702b..4a36fc5 100644 --- a/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs +++ b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs @@ -6,6 +6,10 @@ namespace ScadaLink.CLI.Commands; /// /// Filter + destination arguments for an audit export invocation. Mirrors the /// Bundle B GET /api/audit/export parameters. +/// /// +/// are multi-valued — each supplied value becomes a repeated query-string param so +/// the server's multi-value IN (…) filter sees the full set, exactly like +/// the audit query subcommand. /// 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(); + public string[] Kind { get; set; } = Array.Empty(); + public string[] Status { get; set; } = Array.Empty(); + public string[] Site { get; set; } = Array.Empty(); public string? Target { get; set; } public string? Actor { get; set; } } @@ -31,7 +35,11 @@ public static class AuditExportHelpers /// /// Builds the ?... query string for GET /api/audit/export: the required /// time window + format, plus optional filters. Time-specs are resolved via - /// . + /// . The multi-valued + /// --channel/--kind/--status/--site filters each emit ONE + /// repeated query-string key per value (e.g. channel=A&channel=B) so the + /// server's multi-value IN (…) filter receives the full set — mirroring + /// . /// 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 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); diff --git a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs index f18f971..39918f5 100644 --- a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs +++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs @@ -9,15 +9,18 @@ namespace ScadaLink.CLI.Commands; /// Filter arguments for an audit query invocation. Mirrors the Bundle B /// GET /api/audit/query filter parameters; / /// are time-specs (relative like 1h/7d, or absolute ISO-8601). +/// /// +/// are multi-valued — each supplied value becomes a repeated query-string param so +/// the server's multi-value IN (…) filter sees the full set. /// 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(); + public string[] Kind { get; set; } = Array.Empty(); + public string[] Status { get; set; } = Array.Empty(); + public string[] Site { get; set; } = Array.Empty(); public string? Target { get; set; } public string? Actor { get; set; } public string? CorrelationId { get; set; } @@ -73,8 +76,11 @@ public static class AuditQueryHelpers /// /// Builds the ?... query string for GET /api/audit/query from the filter - /// args plus an optional keyset cursor. Unset filters are omitted. --errors-only - /// maps to status=Failed (the server takes a single status value). + /// args plus an optional keyset cursor. Unset filters are omitted. The multi-valued + /// --channel/--kind/--status/--site filters each emit ONE + /// repeated query-string key per value (e.g. channel=A&channel=B) so the + /// server's multi-value IN (…) filter receives the full set. --errors-only + /// maps to a single status=Failed and overrides any explicit --status. /// public static string BuildQueryString( AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) @@ -87,20 +93,35 @@ public static class AuditQueryHelpers parts.Add($"{key}={Uri.EscapeDataString(value)}"); } + void AddEach(string key, IReadOnlyList 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); diff --git a/src/ScadaLink.CLI/README.md b/src/ScadaLink.CLI/README.md index eed1ccf..d17e794 100644 --- a/src/ScadaLink.CLI/README.md +++ b/src/ScadaLink.CLI/README.md @@ -1078,10 +1078,10 @@ scadalink --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 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 diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs index fdf34f6..c369195 100644 --- a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs +++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs @@ -74,34 +74,27 @@ public static class AuditExportEndpoints } /// - /// Parses the query-string into an . - /// Unknown enum names / un-parseable Guids / dates are silently dropped - /// (same contract as AuditLogPage.ApplyQueryStringFilters). + /// Parses the query-string into an . The + /// channel/kind/status/site 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 + /// AuditLogPage.ApplyQueryStringFilters) — an unparseable value within + /// a repeated set is dropped, not the whole set. /// + /// + /// This endpoint reads the source-site filter from the site query key, + /// whereas the ManagementService export endpoint reads it as + /// sourceSiteId. The divergence is deliberate — each endpoint matches + /// its own CLI / UI URL builder — so do NOT "fix" the two to one key name. + /// internal static AuditLogQueryFilter ParseFilter(IQueryCollection query) { - AuditChannel? channel = null; - if (query.TryGetValue("channel", out var channelValues) - && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) - { - channel = parsedChannel; - } + var channels = AuditQueryParamParsers.ParseEnumList(query["channel"]); + var kinds = AuditQueryParamParsers.ParseEnumList(query["kind"]); + var statuses = AuditQueryParamParsers.ParseEnumList(query["status"]); + var sites = AuditQueryParamParsers.ParseStringList(query["site"]); - AuditKind? kind = null; - if (query.TryGetValue("kind", out var kindValues) - && Enum.TryParse(kindValues.ToString(), ignoreCase: true, out var parsedKind)) - { - kind = parsedKind; - } - - AuditStatus? status = null; - if (query.TryGetValue("status", out var statusValues) - && Enum.TryParse(statusValues.ToString(), ignoreCase: true, out var parsedStatus)) - { - status = parsedStatus; - } - - string? site = TrimToNullable(query, "site"); string? target = TrimToNullable(query, "target"); string? actor = TrimToNullable(query, "actor"); @@ -116,10 +109,10 @@ public static class AuditExportEndpoints 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, diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs index 6ed9e70..08e8582 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs @@ -15,20 +15,20 @@ namespace ScadaLink.CentralUI.Components.Audit; /// /// /// -/// The repository filter contract () is single-value -/// per dimension today; the chip multi-selects therefore collapse to the FIRST -/// selected chip when the model is published via . 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 () is multi-value +/// per dimension: the chip multi-selects map straight through to the +/// Channels / Kinds / Statuses / SourceSiteIds filter +/// lists when the model is published via — 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. /// /// /// /// The Errors-only toggle is a convenience: when true AND no explicit Status chips -/// are selected, the collapsed filter pins (the -/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle -/// is a no-op — the explicit Status filter wins. +/// are selected, targets the full error-status set +/// {, , +/// }. When Status chips ARE selected the toggle +/// is a no-op — the explicit Status chips win. /// /// public sealed class AuditQueryModel @@ -104,20 +104,21 @@ public sealed class AuditQueryModel } /// - /// 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 + /// null — "do not constrain"). See class doc for the Errors-only rule. /// public AuditLogQueryFilter ToFilter(DateTime utcNow) { - var status = ResolveStatus(); + var statuses = ResolveStatuses(); var (fromUtc, toUtc) = ResolveTimeWindow(utcNow); 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, @@ -125,20 +126,22 @@ public sealed class AuditQueryModel ToUtc: toUtc); } - private AuditStatus? ResolveStatus() + /// The non-success statuses targeted by the Errors-only toggle. + private static readonly AuditStatus[] ErrorStatuses = + { AuditStatus.Failed, AuditStatus.Parked, AuditStatus.Discarded }; + + private IReadOnlyList? 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; diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor index df000aa..af2fb0d 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor @@ -12,12 +12,26 @@ }
- +
@foreach (var col in OrderedColumns()) { - + // @key keeps Blazor reusing one DOM node per column across + // re-renders (reorder/resize), so audit-grid.js binds drag + // listeners exactly once per } @@ -48,7 +62,7 @@ @onclick="() => HandleRowClick(row)"> @foreach (var col in OrderedColumns()) { - } diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs index cfbae61..d6b08c4 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs @@ -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; @@ -14,12 +16,15 @@ namespace ScadaLink.CentralUI.Components.Audit; /// source without standing up EF Core. /// /// -/// Column model. Each column has a stable string key; the visible order -/// is the 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 .table-responsive wrapper. +/// Column model. Each column has a stable string key. The default +/// visible order is the 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 sessionStorage, 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. /// /// /// @@ -32,11 +37,28 @@ namespace ScadaLink.CentralUI.Components.Audit; /// rows) — that's the conventional "we've reached the /// end" signal for keyset paging without a count query. /// +/// +/// +/// Accessibility. 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- +/// customisation 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. +/// /// -public partial class AuditResultsGrid +public partial class AuditResultsGrid : IAsyncDisposable { private const int DefaultPageSize = 100; + /// Minimum persisted column width — mirrors auditGrid.minWidth. + private const int MinColumnWidthPx = 64; + + /// sessionStorage keys (namespaced under auditGrid: by the JS helper). + private const string ColumnOrderStorageKey = "columnOrder"; + private const string ColumnWidthsStorageKey = "columnWidths"; + private readonly List _rows = new(); private int _pageNumber = 1; private bool _loading; @@ -44,6 +66,18 @@ public partial class AuditResultsGrid private AuditLogQueryFilter? _activeFilter; + [Inject] private IJSRuntime JS { get; set; } = default!; + + private ElementReference _tableRef; + private DotNetObjectReference? _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? _columnOrder; + private readonly Dictionary _columnWidths = new(); + /// /// 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 +109,9 @@ public partial class AuditResultsGrid /// data-test + the column-order parameter); the label is the user-facing /// header text. Mirrors Component-AuditLog.md §10. /// + // 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"), @@ -90,24 +127,57 @@ public partial class AuditResultsGrid }; private IReadOnlyList<(string Key, string Label)> OrderedColumns() + => ResolveOrder(_columnOrder ?? ColumnOrder); + + /// + /// 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. + /// + private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList? 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(); + 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; } + /// + /// Inline style for a column's cells: emits the --audit-col-width + /// custom property the scoped stylesheet reads, or an empty string when + /// the column has no persisted width (auto layout). + /// + 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 +250,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
@col.Label and never leaks them onto + // discarded nodes — the __auditGridCellBound guard relies on + // this node stability to be fully sound. + + @col.Label + +
+ @RenderCell(col.Key, row) 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. + } + } + } + + /// + /// Reads the persisted column order + widths from sessionStorage 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. + /// + 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>(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>(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 TryLoadAsync(string key) + { + try + { + return await JS.InvokeAsync("auditGrid.load", key); + } + catch (JSDisconnectedException) + { + return null; + } + } + + /// + /// JS callback: the user finished resizing a column. Persists the new + /// per-column width and re-renders so the body cells track the header. + /// + [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(); + } + + /// + /// JS callback: the user dropped column onto the + /// header of . Moves the dragged column into the + /// target's slot, persists the resulting order, and re-renders. + /// + [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", diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css new file mode 100644 index 0000000..f89edad --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css @@ -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 and matching 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); +} diff --git a/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor b/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor new file mode 100644 index 0000000..624e4a2 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor @@ -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 + +
+
Site Calls
+ View details → +
+
+ @* ── Buffered tile ─────────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Stuck tile ────────────────────────────────────────────────────────── *@ +
+ +
+ + @* ── Parked tile ───────────────────────────────────────────────────────── *@ +
+ +
+
+@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage)) +{ +
Site Call KPIs unavailable: @ErrorMessage
+} diff --git a/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs b/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs new file mode 100644 index 0000000..1ed9a9a --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Messages.Audit; + +namespace ScadaLink.CentralUI.Components.Health; + +/// +/// Site Call Audit (#22) Task 7 code-behind for . +/// Renders three KPI tiles — Buffered, Stuck, Parked — from a +/// the parent Health dashboard supplies. +/// Tiles act as drill-in links: clicking navigates to /site-calls/report +/// with the relevant query-string filter pre-applied. Mirrors +/// and the Notification Outbox KPI section on the +/// Health dashboard. +/// +/// +/// +/// Why purely presentational. 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 every refresh and the +/// tile component re-renders. This is the same contract +/// follows. +/// +/// +/// Snapshot shape. Unlike — which takes a +/// dedicated AuditLogKpiSnapshot type — Site Call KPIs travel in the +/// message itself (it carries the KPI fields +/// directly), so that record doubles as the snapshot here. +/// is a separate flag rather than the record's own Success so the parent +/// can also surface a transport failure (an Ask that threw) as unavailable. +/// +/// +/// Threshold borders. Mirrors the Notification Outbox tile pattern: the +/// Parked tile gets a danger border when ParkedCount > 0; the Stuck +/// tile gets a warning border when StuckCount > 0. Buffered is a plain +/// count tile with no threshold colour — a non-zero buffer is normal operation. +/// +/// +public partial class SiteCallKpiTiles +{ + /// + /// Latest KPI snapshot. null means the parent has not loaded it yet + /// or the load failed — the tiles render em dashes in that case. + /// + [Parameter] public SiteCallKpiResponse? Snapshot { get; set; } + + /// + /// True when 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. + /// + [Parameter] public bool IsAvailable { get; set; } + + /// + /// Optional error message to render underneath the tiles when + /// is false. Mirrors how the Notification Outbox + /// section on the Health dashboard surfaces transient KPI failures. + /// + [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"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 1c05b7e..5dc07e7 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -91,6 +91,19 @@ + @* 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. *@ + + + + + + + @* Monitoring — Health Dashboard is all-roles; Event Logs and Parked Messages are Deployment-role only (Component-CentralUI). *@ diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index b3c05ff..bd2796e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -19,7 +19,7 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; /// /// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can /// deep-link to a pre-filtered Audit Log: ?correlationId=, ?target=, -/// ?actor=, ?site=, ?channel=, and the UI-only +/// ?actor=, ?site=, ?channel=, ?kind=, and the UI-only /// ?instance= are read on initialization. Bundle E (M7-T13) extends /// this with ?status= so the Health-dashboard Audit error-rate tile can /// drill in to ?status=Failed. When any param is present we allocate a @@ -80,33 +80,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? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site")); - AuditChannel? channel = null; - if (query.TryGetValue("channel", out var channelValues) - && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) - { - channel = parsedChannel; - } + IReadOnlyList? channels = + AuditQueryParamParsers.ParseEnumList(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? kinds = + AuditQueryParamParsers.ParseEnumList(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(statusValues.ToString(), ignoreCase: true, out var parsedStatus)) - { - status = parsedStatus; - } + IReadOnlyList? statuses = + AuditQueryParamParsers.ParseEnumList(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 +117,33 @@ 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 && 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); } + /// + /// Extracts the raw repeated values for one query-string key, returning + /// null when the key is absent so the shared + /// sees the same absent-vs-present + /// distinction the ASP.NET IQueryCollection callers do. + /// StringValues is itself an IEnumerable<string?>. + /// + private static IEnumerable? Raw( + Dictionary query, string key) => + query.TryGetValue(key, out var values) ? (IEnumerable)values : null; + private void HandleFilterChanged(AuditLogQueryFilter filter) { // Always reassign — the grid keys reloads on reference change, so even a @@ -180,22 +187,42 @@ public partial class AuditLogPage return basePath; } - var parts = new List>(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>(); + // 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)) { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor index 58a87d1..198a161 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -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 @@
Notification Outbox KPIs unavailable: @_outboxKpiError
} + @* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel + (buffered / stuck / parked). Refreshed alongside the site states. *@ + + @* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel (volume / error rate / backlog). Refreshed alongside the site states. *@ diff --git a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor new file mode 100644 index 0000000..30ece7b --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor @@ -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 Logger + +
+ + +
+

Site Calls

+ +
+ + @* ── Filters ── *@ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ + @if (_listError != null) + { +
@_listError
+ } + + @* ── Site call list ── *@ + @if (_siteCalls == null) + { + @if (_loading) + { +
Loading…
+ } + } + else if (_siteCalls.Count == 0) + { +
+
+
No site calls
+
No cached calls match the current filters.
+
+
+ } + else + { +
+ + + + + + + + + + + + + + + + + @foreach (var c in _siteCalls) + { + + + + + + + + + + + + + } + +
Tracked operationSource siteChannelTargetStatusRetriesLast errorCreatedUpdatedActions
@ShortId(c.TrackedOperationId)@SiteName(c.SourceSite)@c.Channel@c.Target + @c.Status + @if (c.IsStuck) + { + Stuck + } + @c.RetryCount + @if (!string.IsNullOrEmpty(c.LastError)) + { +
@c.LastError
+ } + else + { + + } +
+ @* 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. *@ + + View audit history + + @* 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") + { + + + } +
+
+ + @* 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. *@ +
+ + @* 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 + +
+ + +
+
+ } +
+ +@* ── Row detail modal ── *@ +@if (_detailSiteCall != null) +{ + var d = _detailSiteCall; + +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs new file mode 100644 index 0000000..c8f0a21 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs @@ -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; + +/// +/// Code-behind for the central Site Calls report page (Site Call Audit #22). A +/// near-mirror of : +/// it queries the central SiteCalls table via +/// , +/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard +/// of Parked cached calls to their owning site. +/// +/// +/// Unlike the Notification report, the query response uses a (CreatedAtUtc DESC, +/// TrackedOperationId DESC) 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 NextAfter* cursor (to step forwards). +/// +/// +/// +/// Retry/Discard relay to the owning site has a distinct +/// 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. +/// +/// +/// +/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here +/// with ?status=Parked (Parked tile) or ?stuck=true (Stuck tile). On +/// initialization those params seed / +/// BEFORE the first , 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 AuditLogPage's drill-in convention. +/// +/// +public partial class SiteCallsReport +{ + private const int PageSize = 50; + + [Inject] private NavigationManager Navigation { get; set; } = null!; + + // The Status filter 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; + } + } + + /// Re-fetch the current page (Refresh button, and after a relay action). + private async Task RefreshAll() + { + await FetchPage(_currentCursor); + } + + /// Apply the filters and start again from the first page. + 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); + } + + /// + /// Fetch one keyset page starting after . + /// + 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; + } + + /// + /// Surface a relay outcome on the toast — exactly one toast per relay + /// response. The case is + /// deliberately distinct from a generic failure: the action was not applied + /// but the operator can retry once the site is back online. + /// + /// + /// The switch is exhaustive, so it owns + /// the single toast. is a redundant + /// cross-check on the same signal (the contract sets it false only + /// for ); it is folded + /// INTO the case rather + /// than firing a second toast — an OperationFailed response that also + /// reports an unreachable site shows the unreachable wording, once. + /// + 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(); + + /// + /// The filter inputs are UTC wall-clock — stamp + /// on the local-typed value so the query is unambiguous. + /// + private static DateTime? ToUtc(DateTime? value) => + value == null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc); + + /// + /// The SiteCalls timestamps are UTC ; wrap them as + /// a for TimestampDisplay. + /// + 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" + }; +} diff --git a/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js b/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js new file mode 100644 index 0000000..3ede5ed --- /dev/null +++ b/src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js @@ -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
'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 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
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 . 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 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 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 + // width immediately, but the 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"); + } + } +}; diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs index 7bb3790..81c3a48 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/ISiteCallAuditRepository.cs @@ -63,4 +63,27 @@ public interface ISiteCallAuditRepository /// deleted. /// Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default); + + /// + /// Computes a point-in-time global from the + /// SiteCalls table. Counts are aggregated server-side (no row + /// materialisation): StuckCount uses ; + /// FailedLastInterval / DeliveredLastInterval use + /// ; the current time for OldestPendingAge + /// is captured inside the method. + /// + Task ComputeKpisAsync( + DateTime stuckCutoff, + DateTime intervalSince, + CancellationToken ct = default); + + /// + /// Computes a point-in-time per source + /// site. Sites with no SiteCalls rows at all are omitted. The stuck + /// cutoff and interval bounds are interpreted as in . + /// + Task> ComputePerSiteKpisAsync( + DateTime stuckCutoff, + DateTime intervalSince, + CancellationToken ct = default); } diff --git a/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs b/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs new file mode 100644 index 0000000..d5c98a4 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs @@ -0,0 +1,163 @@ +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.Commons.Messages.Audit; + +/// +/// Site Calls UI -> Central: paginated, filtered query over the central +/// SiteCalls table (Site Call Audit #22). All filter fields are optional; +/// restricts results to stuck cached calls. Mirrors +/// +/// but uses keyset paging ( + ) +/// to match the repository's (CreatedAtUtc DESC, TrackedOperationId DESC) +/// cursor, rather than page numbers. +/// +/// +/// matches the SiteCall.Channel column — +/// "ApiOutbound" or "DbOutbound" (the spec's Kind notion; +/// the entity exposes it as Channel). is an +/// exact-match target filter, consistent with the repository's +/// predicate. +/// +/// +/// Requested page size. The actor clamps this to the [1, 200] range, so +/// the effective ceiling is 200 rows per page regardless of the value sent. +/// +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); + +/// +/// A single SiteCalls row summarised for the Site Calls UI grid. Carries +/// only the columns the +/// entity genuinely exposes — there are no source-instance/script provenance +/// columns on that entity, so unlike +/// +/// none are surfaced here. +/// +/// +/// is not called out in the Site Call Audit plan, but +/// it is a real (nullable) +/// column — the last HTTP status code observed for the call — so it is surfaced +/// here for the grid; null for non-HTTP channels or before a first attempt. +/// +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); + +/// +/// Central -> Site Calls UI: paginated response for a . +/// The keyset cursor of the last row is echoed back as +/// + for the caller +/// to request the following page; both are null when the page was empty. +/// On a repository fault is false, +/// carries the cause and is empty. +/// +public sealed record SiteCallQueryResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + IReadOnlyList SiteCalls, + DateTime? NextAfterCreatedAtUtc, + Guid? NextAfterId); + +/// +/// Site Calls UI -> Central: request for the full detail of a single cached call, +/// for the report detail modal. +/// +public sealed record SiteCallDetailRequest( + string CorrelationId, + Guid TrackedOperationId); + +/// +/// Central -> Site Calls UI: full detail for one cached call. On a repository +/// fault or missing row, is false / +/// is null and carries +/// the cause. +/// +public sealed record SiteCallDetailResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + SiteCallDetail? Detail); + +/// +/// Full SiteCalls row detail for the report detail modal — every field +/// on the entity, +/// including and the +/// timestamp the grid summary omits. +/// +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); + +/// +/// Site Calls UI -> Central: request for the global SiteCalls KPI summary. +/// Mirrors . +/// +public sealed record SiteCallKpiRequest( + string CorrelationId); + +/// +/// Central -> Site Calls UI: KPI summary for the Site Calls dashboard. On a +/// repository fault is false, +/// carries the cause, and the KPI fields are +/// zeroed/null. +/// +public sealed record SiteCallKpiResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + int BufferedCount, + int ParkedCount, + int FailedLastInterval, + int DeliveredLastInterval, + TimeSpan? OldestPendingAge, + int StuckCount); + +/// +/// Site Calls UI -> Central: request for the per-source-site SiteCalls +/// KPI breakdown. Mirrors +/// . +/// +public sealed record PerSiteSiteCallKpiRequest( + string CorrelationId); + +/// +/// Central -> Site Calls UI: per-site KPI breakdown for the Site Calls KPIs +/// page. On a repository fault is false, +/// carries the cause, and is empty. +/// +public sealed record PerSiteSiteCallKpiResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + IReadOnlyList Sites); diff --git a/src/ScadaLink.Commons/Messages/Audit/SiteCallRelayMessages.cs b/src/ScadaLink.Commons/Messages/Audit/SiteCallRelayMessages.cs new file mode 100644 index 0000000..62039e6 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Audit/SiteCallRelayMessages.cs @@ -0,0 +1,113 @@ +namespace ScadaLink.Commons.Messages.Audit; + +/// +/// Outcome of a Site Call Audit (#22) Retry/Discard relay — distinguishes the +/// three cases the Central UI Site Calls page must surface differently. +/// +/// +/// The "site unreachable" case is deliberately separate from +/// : 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. +/// +public enum SiteCallRelayOutcome +{ + /// + /// 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. + /// + Applied, + + /// + /// 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 Parked). A definitive answer from the site, not a failure. + /// + NotParked, + + /// + /// 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. + /// + SiteUnreachable, + + /// + /// The owning site was reached but reported it could not apply the action + /// (its parked-message handler was unavailable or its store faulted). + /// + OperationFailed, +} + +/// +/// 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 SiteCalls +/// mirror row. Mirrors +/// +/// but carries (the relay target) and answers with a +/// distinct site-unreachable outcome. +/// +/// Request correlation id, echoed on the response. +/// +/// The cached operation to retry — the PK of the central SiteCalls row +/// and the S&F buffer message id at the owning site. +/// +/// +/// The owning site (SiteCall.SourceSite) the relay is routed to. +/// +public sealed record RetrySiteCallRequest( + string CorrelationId, + Guid TrackedOperationId, + string SourceSite); + +/// +/// Site Call Audit → Central UI: result of a . +/// +/// Echoed request correlation id. +/// +/// The relay outcome — , +/// , +/// or +/// . +/// +/// +/// Convenience flag — true only for . +/// +/// +/// false only for ; lets +/// the UI distinguish "site offline" from "operation failed" without switching +/// on the enum. +/// +/// +/// Human-readable detail for a non-applied outcome; null on success. +/// +public sealed record RetrySiteCallResponse( + string CorrelationId, + SiteCallRelayOutcome Outcome, + bool Success, + bool SiteReachable, + string? ErrorMessage); + +/// +/// Central UI → Site Call Audit: relay a Discard of a parked cached call to its +/// owning site. See for the source-of-truth +/// and routing rationale. +/// +public sealed record DiscardSiteCallRequest( + string CorrelationId, + Guid TrackedOperationId, + string SourceSite); + +/// +/// Site Call Audit → Central UI: result of a . +/// Same shape as . +/// +public sealed record DiscardSiteCallResponse( + string CorrelationId, + SiteCallRelayOutcome Outcome, + bool Success, + bool SiteReachable, + string? ErrorMessage); diff --git a/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedOperationRelayMessages.cs b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedOperationRelayMessages.cs new file mode 100644 index 0000000..0d60789 --- /dev/null +++ b/src/ScadaLink.Commons/Messages/RemoteQuery/ParkedOperationRelayMessages.cs @@ -0,0 +1,75 @@ +using ScadaLink.Commons.Types; + +namespace ScadaLink.Commons.Messages.RemoteQuery; + +/// +/// Central → site relay command: retry a parked cached operation +/// (ExternalSystem.CachedCall / Database.CachedWrite) on the +/// owning site's Store-and-Forward buffer. Sent over the command/control +/// channel by SiteCallAuditActor when an operator clicks Retry on a +/// Parked Site Call row in the Central UI. +/// +/// +/// +/// The site is the source of truth for cached-call status — central never +/// mutates the central SiteCalls mirror row directly. This command asks +/// the site to reset its own parked row back to Pending so the S&F +/// retry sweep attempts delivery again; the corrected state then flows back to +/// central via the normal cached-call telemetry path. +/// +/// +/// The cached call's S&F buffer message id is the +/// 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 . A retry on a row that is not +/// actually Parked is a safe no-op at the site — the ack reports +/// Applied=false rather than corrupting a non-parked row. +/// +/// +/// This is a plain record carrying only ids, so it lives in Commons (no +/// IActorRef field). It mirrors +/// but keys on rather than the opaque S&F +/// message-id string. +/// +/// +public sealed record RetryParkedOperation( + string CorrelationId, + TrackedOperationId TrackedOperationId); + +/// +/// 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 SiteCallAuditActor when an operator clicks Discard on a +/// Parked Site Call row in the Central UI. See +/// for the source-of-truth and message-id +/// rationale; Discard marks the operation terminally Discarded at the +/// site by removing the parked S&F buffer row. +/// +public sealed record DiscardParkedOperation( + string CorrelationId, + TrackedOperationId TrackedOperationId); + +/// +/// Site → central ack for a / +/// relay command. The site replies this +/// after applying (or safely no-op-ing) the action against its own +/// Store-and-Forward buffer. +/// +/// Correlation id of the originating relay command. +/// +/// true when the parked operation was found and the action was applied; +/// false when no parked row matched the +/// (already delivered, discarded, never cached, or not in a Parked +/// state). A false 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. +/// +/// +/// 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); null +/// on a clean Applied=true/Applied=false outcome. +/// +public sealed record ParkedOperationActionAck( + string CorrelationId, + bool Applied, + string? ErrorMessage = null); diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs index 4e2001a..5399f48 100644 --- a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs +++ b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs @@ -4,16 +4,20 @@ namespace ScadaLink.Commons.Types.Audit; /// /// Filter predicate for . -/// Any field left null means "do not constrain on that column". Time bounds -/// are half-open in the spec sense — is inclusive and -/// is inclusive of the upper bound; the repository SQL uses -/// >= / <= respectively. All filter fields are AND-combined. +/// Any field left null means "do not constrain on that column". The +/// , , and +/// dimensions are multi-value: a null OR empty +/// list means "do not constrain", and a non-empty list is OR-combined within the +/// dimension (translated to a SQL IN (…)). Time bounds are half-open in +/// the spec sense — is inclusive and is +/// inclusive of the upper bound; the repository SQL uses >= / <= +/// respectively. All filter dimensions are AND-combined with one another. /// public sealed record AuditLogQueryFilter( - AuditChannel? Channel = null, - AuditKind? Kind = null, - AuditStatus? Status = null, - string? SourceSiteId = null, + IReadOnlyList? Channels = null, + IReadOnlyList? Kinds = null, + IReadOnlyList? Statuses = null, + IReadOnlyList? SourceSiteIds = null, string? Target = null, string? Actor = null, Guid? CorrelationId = null, diff --git a/src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs b/src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs new file mode 100644 index 0000000..adf1c1a --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs @@ -0,0 +1,79 @@ +namespace ScadaLink.Commons.Types.Audit; + +/// +/// Shared lax parsers for the multi-value Audit Log query parameters +/// (channel/kind/status/site). The Audit Log filter +/// wire-contract is consumed by three surfaces that MUST stay in lockstep: +/// +/// the ManagementService /api/audit/query + /api/audit/export +/// endpoints, +/// the CentralUI /api/centralui/audit/export endpoint, and +/// the CentralUI AuditLogPage query-string drill-in parser. +/// +/// +/// +/// Each caller extracts the raw repeated values for a single parameter from its +/// own request type (ASP.NET IQueryCollection, a +/// Dictionary<string, StringValues> from QueryHelpers.ParseQuery, +/// etc.) and passes them here as a plain of strings — +/// so this helper carries NO ASP.NET / Microsoft.Extensions.Primitives +/// dependency and can live in ScadaLink.Commons. +/// +/// +/// +/// Lax-parse contract. 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 null so +/// the corresponding filter dimension stays unconstrained. +/// +/// +public static class AuditQueryParamParsers +{ + /// + /// Parses each raw value as (case-insensitive), + /// dropping unparseable values silently. Returns null when + /// is null, empty, or yields no parseable + /// value — so the filter dimension stays unconstrained. + /// + public static IReadOnlyList? ParseEnumList(IEnumerable? rawValues) + where TEnum : struct, Enum + { + if (rawValues is null) + { + return null; + } + + var parsed = new List(); + foreach (var raw in rawValues) + { + if (Enum.TryParse(raw, ignoreCase: true, out var value)) + { + parsed.Add(value); + } + } + return parsed.Count > 0 ? parsed : null; + } + + /// + /// Trims each raw value and drops blank entries. Returns null when + /// is null, empty, or every value was + /// blank. + /// + public static IReadOnlyList? ParseStringList(IEnumerable? rawValues) + { + if (rawValues is null) + { + return null; + } + + var parsed = new List(); + foreach (var raw in rawValues) + { + if (!string.IsNullOrWhiteSpace(raw)) + { + parsed.Add(raw.Trim()); + } + } + return parsed.Count > 0 ? parsed : null; + } +} diff --git a/src/ScadaLink.Commons/Types/Audit/SiteCallKpiSnapshot.cs b/src/ScadaLink.Commons/Types/Audit/SiteCallKpiSnapshot.cs new file mode 100644 index 0000000..fa07c8f --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/SiteCallKpiSnapshot.cs @@ -0,0 +1,38 @@ +namespace ScadaLink.Commons.Types.Audit; + +/// +/// Point-in-time operational metrics for the central SiteCalls table +/// (Site Call Audit #22), surfaced on the health dashboard. The cached-call +/// counterpart of ; +/// mirrors its shape so the Central UI Site Calls KPI tiles can reuse the +/// Notification Outbox tile layout. +/// +/// +/// Count of non-terminal rows (TerminalAtUtc IS NULL) — calls +/// buffered at sites awaiting retry. +/// +/// Count of rows in the Parked status. +/// +/// Count of Failed rows whose +/// is at or after the supplied "since" timestamp. +/// +/// +/// Count of Delivered rows whose +/// is at or after the supplied "since" timestamp. +/// +/// +/// Age of the oldest non-terminal row (now - min(CreatedAtUtc)), or +/// null when there are no non-terminal rows. +/// +/// +/// Count of non-terminal rows (TerminalAtUtc IS NULL) whose +/// is older +/// than the supplied stuck cutoff. Display-only — no escalation. +/// +public sealed record SiteCallKpiSnapshot( + int BufferedCount, + int ParkedCount, + int FailedLastInterval, + int DeliveredLastInterval, + TimeSpan? OldestPendingAge, + int StuckCount); diff --git a/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs index cf7e7d4..63f0c58 100644 --- a/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs +++ b/src/ScadaLink.Commons/Types/Audit/SiteCallQueryFilter.cs @@ -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. /// +/// Restrict to a single channel (exact match). +/// Restrict to a single source site (exact match). +/// Restrict to a single status (exact match). +/// Restrict to a single target (exact match). +/// Inclusive lower bound on CreatedAtUtc. +/// Inclusive upper bound on CreatedAtUtc. +/// +/// When set, restrict to stuck rows: TerminalAtUtc IS NULL AND CreatedAtUtc < +/// StuckCutoffUtc. Both columns are plain (no value converter) and compose +/// directly with the keyset cursor. Mirrors +/// ; +/// keeps the "StuckOnly" filter honest so paging never returns under-filled +/// pages with a non-null next cursor. +/// 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); diff --git a/src/ScadaLink.Commons/Types/Audit/SiteCallSiteKpiSnapshot.cs b/src/ScadaLink.Commons/Types/Audit/SiteCallSiteKpiSnapshot.cs new file mode 100644 index 0000000..c5c8208 --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/SiteCallSiteKpiSnapshot.cs @@ -0,0 +1,34 @@ +namespace ScadaLink.Commons.Types.Audit; + +/// +/// Point-in-time SiteCalls metrics scoped to a single source site. The +/// per-site counterpart of ; surfaced in the +/// per-site breakdown table on the Site Calls KPIs page. Mirrors +/// . +/// +/// The site identifier these metrics are scoped to. +/// Count of this site's non-terminal rows (TerminalAtUtc IS NULL). +/// Count of this site's rows in the Parked status. +/// +/// Count of this site's Failed rows whose TerminalAtUtc is at or +/// after the "since" timestamp. +/// +/// +/// Count of this site's Delivered rows whose TerminalAtUtc is at +/// or after the "since" timestamp. +/// +/// +/// Age of this site's oldest non-terminal row, or null when it has none. +/// +/// +/// Count of this site's non-terminal rows whose CreatedAtUtc is older +/// than the stuck cutoff. +/// +public sealed record SiteCallSiteKpiSnapshot( + string SourceSite, + int BufferedCount, + int ParkedCount, + int FailedLastInterval, + int DeliveredLastInterval, + TimeSpan? OldestPendingAge, + int StuckCount); diff --git a/src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs b/src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs index 505c516..319a267 100644 --- a/src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs +++ b/src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs @@ -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 /// private IActorRef? _notificationOutboxProxy; + /// + /// Proxy for the central AuditLogIngestActor cluster + /// singleton. Set via — the Host creates the + /// singleton proxy after this actor and registers it (mirrors + /// ). 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 + /// to the caller — the fault propagates rather + /// than being swallowed. This differs from the gRPC handler + /// (SiteStreamGrpcServer), which catches the exception and returns an + /// empty ack; here the faulted Ask is the transient signal the site relies on + /// (see ). + /// + private IActorRef? _auditIngestProxy; + + /// + /// Default Ask timeout for routing audit ingest commands to the + /// AuditLogIngestActor proxy — 30 s, matching the value of + /// SiteStreamGrpcServer.AuditIngestAskTimeout (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 ). + /// + private static readonly TimeSpan DefaultAuditIngestAskTimeout = TimeSpan.FromSeconds(30); + + /// + /// Effective Ask timeout for audit ingest routing. Defaults to + /// ; overridable via the constructor + /// so tests can exercise the timeout/fault path without waiting 30 s. + /// + private readonly TimeSpan _auditIngestAskTimeout; + /// /// 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 /// private const string HealthReportTopic = "site-health-replica"; - public CentralCommunicationActor(IServiceProvider serviceProvider, ISiteClientFactory siteClientFactory) + /// + /// Optional override for the audit-ingest Ask timeout; defaults to + /// (30 s). Exists only so tests can + /// exercise the timeout/fault path quickly — production always uses the default. + /// + public CentralCommunicationActor( + IServiceProvider serviceProvider, + ISiteClientFactory siteClientFactory, + TimeSpan? auditIngestAskTimeout = null) { _serviceProvider = serviceProvider; _siteClientFactory = siteClientFactory; + _auditIngestAskTimeout = auditIngestAskTimeout ?? DefaultAuditIngestAskTimeout; // Site address cache loaded from database Receive(HandleSiteAddressCacheLoaded); @@ -133,6 +180,24 @@ public class CentralCommunicationActor : ReceiveActor // so the NotificationStatusResponse routes back to the querying site. Receive(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(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(HandleIngestAuditEvents); + + // Audit Log (#23 M3) combined-telemetry ingest: routes to the same proxy + // the same way; the proxy replies with an IngestCachedTelemetryReply. + Receive(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())); + 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(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())); + return; + } + + var replyTo = Sender; + _log.Debug("Routing IngestCachedTelemetryCommand ({0} entries) to the audit ingest actor", msg.Entries.Count); + _auditIngestProxy.Ask(msg, _auditIngestAskTimeout) + .PipeTo(replyTo); + } + private void HandleHeartbeat(HeartbeatMessage heartbeat) { var aggregator = _serviceProvider.GetService(); @@ -464,3 +574,14 @@ public record DebugStreamTerminated(string SiteId, string CorrelationId); /// after the outbox singleton proxy is created. /// public record RegisterNotificationOutbox(IActorRef OutboxProxy); + +/// +/// Registers the central AuditLogIngestActor singleton proxy with the +/// so site-forwarded +/// and +/// messages can be routed to it. Sent by the Host after the audit-ingest +/// singleton proxy is created. Lives here (not in Commons) because +/// ScadaLink.Commons has no Akka package reference and cannot hold an +/// field. +/// +public sealed record RegisterAuditIngest(IActorRef AuditIngestActor); diff --git a/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs b/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs index 21094b1..42d7635 100644 --- a/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs @@ -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(msg => + { + if (_parkedMessageHandler != null) + _parkedMessageHandler.Forward(msg); + else + { + Sender.Tell(new ParkedOperationActionAck( + msg.CorrelationId, Applied: false, "Parked message handler not available")); + } + }); + + Receive(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(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(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(_ => SendHeartbeatToCentral()); diff --git a/src/ScadaLink.Communication/CommunicationService.cs b/src/ScadaLink.Communication/CommunicationService.cs index a6ea2c7..4ef5169 100644 --- a/src/ScadaLink.Communication/CommunicationService.cs +++ b/src/ScadaLink.Communication/CommunicationService.cs @@ -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 _logger; private IActorRef? _centralCommunicationActor; private IActorRef? _notificationOutboxProxy; + private IActorRef? _siteCallAuditProxy; public CommunicationService( IOptions options, @@ -52,6 +54,17 @@ public class CommunicationService _notificationOutboxProxy = notificationOutboxProxy; } + /// + /// 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 . + /// + public void SetSiteCallAudit(IActorRef siteCallAuditProxy) + { + _siteCallAuditProxy = siteCallAuditProxy; + } + /// /// Triggers an immediate refresh of the site address cache from the database. /// @@ -80,6 +93,15 @@ public class CommunicationService ?? throw new InvalidOperationException("CommunicationService not initialized. NotificationOutbox proxy not set."); } + /// + /// Gets the Site Call Audit proxy reference. Throws if not yet initialized. + /// + private IActorRef GetSiteCallAudit() + { + return _siteCallAuditProxy + ?? throw new InvalidOperationException("CommunicationService not initialized. SiteCallAudit proxy not set."); + } + // ── Pattern 1: Instance Deployment ── public async Task DeployInstanceAsync( @@ -295,6 +317,71 @@ public class CommunicationService return await GetNotificationOutbox().Ask( request, _options.QueryTimeout, cancellationToken); } + + // ── Site Call Audit (central-local actor — Asked directly, no SiteEnvelope) ── + + public async Task QuerySiteCallsAsync( + SiteCallQueryRequest request, CancellationToken cancellationToken = default) + { + return await GetSiteCallAudit().Ask( + request, _options.QueryTimeout, cancellationToken); + } + + public async Task GetSiteCallDetailAsync( + SiteCallDetailRequest request, CancellationToken cancellationToken = default) + { + return await GetSiteCallAudit().Ask( + request, _options.QueryTimeout, cancellationToken); + } + + public async Task GetSiteCallKpisAsync( + SiteCallKpiRequest request, CancellationToken cancellationToken = default) + { + return await GetSiteCallAudit().Ask( + request, _options.QueryTimeout, cancellationToken); + } + + public async Task GetPerSiteSiteCallKpisAsync( + PerSiteSiteCallKpiRequest request, CancellationToken cancellationToken = default) + { + return await GetSiteCallAudit().Ask( + request, _options.QueryTimeout, cancellationToken); + } + + /// + /// Task 5 (#22): relays an operator Retry of a parked cached call to its + /// owning site. The SiteCallAuditActor is Asked directly (it is + /// central-local); it in turn relays a RetryParkedOperation to the + /// owning site and replies a carrying a + /// distinct site-unreachable outcome. Central never mutates the central + /// SiteCalls mirror row. + /// + /// This outer Ask uses + /// (default 30s), which must outlive the inner site relay Ask the + /// SiteCallAuditActor issues with SiteCallAuditOptions.RelayTimeout + /// (default 10s). The inner relay must time out first so its distinct + /// SiteUnreachable outcome reaches us; were this outer Ask to expire + /// first, that outcome would be lost to a generic Ask-timeout exception. + /// + /// + public async Task RetrySiteCallAsync( + RetrySiteCallRequest request, CancellationToken cancellationToken = default) + { + return await GetSiteCallAudit().Ask( + request, _options.QueryTimeout, cancellationToken); + } + + /// + /// Task 5 (#22): relays an operator Discard of a parked cached call to its + /// owning site. See for the routing and + /// source-of-truth rationale. + /// + public async Task DiscardSiteCallAsync( + DiscardSiteCallRequest request, CancellationToken cancellationToken = default) + { + return await GetSiteCallAudit().Ask( + request, _options.QueryTimeout, cancellationToken); + } } /// diff --git a/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs similarity index 84% rename from src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs rename to src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs index d821db0..ed5fb22 100644 --- a/src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs +++ b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs @@ -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; /// -/// Bridges Audit Log (#23) rows between the in-process record -/// and the wire-format exchanged over the -/// IngestAuditEvents RPC. +/// Canonical bridge for Audit Log (#23) rows between the in-process +/// record and the wire-format +/// exchanged over the IngestAuditEvents, IngestCachedTelemetry and +/// PullAuditEvents RPCs. /// /// +/// +/// This mapper lives in ScadaLink.Communication (which owns the generated +/// and references Commons for +/// ) so both SiteStreamGrpcServer and +/// ScadaLink.AuditLog can share one implementation without the +/// project-reference cycle that would result from hosting it in +/// ScadaLink.AuditLog (AuditLog → Communication, never the reverse). +/// /// Lossy by design: the proto contract intentionally omits two fields. /// /// — site-local SQLite state, never travels. @@ -22,7 +30,7 @@ namespace ScadaLink.AuditLog.Telemetry; /// Int32Value wrapper so they preserve true null semantics. /// /// -public static class AuditEventMapper +public static class AuditEventDtoMapper { /// /// Projects an into its wire-format DTO. Null reference diff --git a/src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs b/src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs new file mode 100644 index 0000000..c61e3e5 --- /dev/null +++ b/src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs @@ -0,0 +1,70 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types; + +namespace ScadaLink.Communication.Grpc; + +/// +/// Canonical bridge for Site Call Audit (#22) operational rows between the +/// wire-format exchanged on the +/// CachedCallTelemetry packet and the in-process +/// persistence entity central writes into the SiteCalls table. +/// +/// +/// +/// This mapper lives in ScadaLink.Communication (which owns the generated +/// and references Commons for +/// ) so both SiteStreamGrpcServer and +/// ScadaLink.AuditLog can share one implementation without the +/// project-reference cycle that would result from hosting it in +/// ScadaLink.AuditLog (AuditLog → Communication, never the reverse). +/// Mirrors the sibling . +/// +/// +/// Only the DTO→entity direction is provided: nothing in the system maps a +/// back onto the wire (sites emit the operational state +/// from SiteCallOperational, never from the central +/// entity), so an entity→DTO method would be dead code. +/// +/// +/// String nullability convention: proto3 scalar strings cannot be absent, so the +/// optional rehydrates from an empty string back +/// to null. The optional HttpStatus and TerminalAtUtc use proto +/// wrappers so they preserve true null semantics. +/// +/// +public static class SiteCallDtoMapper +{ + /// + /// Reconstructs a persistence entity from its + /// wire-format DTO. An empty LastError rehydrates as null; absent + /// HttpStatus/TerminalAtUtc wrappers stay null. + /// + /// + /// 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. The value sent on the wire is informational only. + /// + 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 + }; + } +} diff --git a/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs b/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs index 8a92027..e75db33 100644 --- a/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs +++ b/src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs @@ -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 /// /// /// - /// The DTO→entity conversion is inlined here (rather than calling the - /// AuditLog mapper) to avoid a project-reference cycle: - /// ScadaLink.AuditLog already references - /// ScadaLink.Communication, so the gRPC server cannot reach back - /// into AuditLog for its mapper. The shape mirrors - /// AuditEventMapper.FromDto in ScadaLink.AuditLog.Telemetry; - /// the two must evolve together. + /// The DTO→entity conversion uses the shared + /// (hosted in ScadaLink.Communication so both this server and + /// ScadaLink.AuditLog share one implementation without a + /// project-reference cycle). /// /// /// When 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(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(dto.Channel), - Kind = Enum.Parse(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(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(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; } - /// - /// Inlined audit-event entity→DTO translation. Keep in sync with - /// AuditEventMapper.ToDto in ScadaLink.AuditLog.Telemetry — - /// the project-reference cycle (AuditLog → Communication) prevents calling - /// the AuditLog mapper directly. The shape mirrors the FromDto pair above. - /// - 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; - - /// - /// Inlined audit-event DTO→entity translation, kept in sync with the - /// 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 - /// AuditEventMapper.FromDto in ScadaLink.AuditLog.Telemetry; - /// the two must evolve together (the project-reference cycle that - /// prevents calling the AuditLog mapper directly is documented on - /// ). - /// - 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(dto.Channel), - Kind = Enum.Parse(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(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, - }; - - /// - /// Translates a into the persistence - /// entity. 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. - /// - 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 - }; - /// /// Tracks a single active stream so cleanup only removes its own entry. /// diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index f517a8e..4a6ce78 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -116,25 +116,28 @@ VALUES var query = _context.Set().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.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)) diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs index 3fdff7e..d90d0d9 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs @@ -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"; + + /// + /// Computes the global KPI snapshot with five server-side aggregate queries + /// against dbo.SiteCalls. No rows are materialised — every count is a + /// translated COUNT and the oldest-pending age is a translated + /// MIN(CreatedAtUtc). The Status and CreatedAtUtc/TerminalAtUtc + /// columns have no value converter, so the aggregates translate cleanly to + /// SQL Server (unlike the NotificationOutbox's DateTimeOffset-converted + /// column, which forces an order-and-take). "Buffered" / "stuck" key off + /// TerminalAtUtc IS NULL — see the field comments above. + /// + public async Task 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); + } + + /// + /// Computes the per-source-site KPI breakdown. The five counts are + /// GROUP BY SourceSite aggregates; the oldest-pending age is a + /// per-site MIN(CreatedAtUtc) 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 TerminalAtUtc IS NULL — see . + /// + public async Task> 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(); + } + + /// Counts SiteCalls rows matching , grouped by source site. + private async Task> CountBySiteAsync( + System.Linq.Expressions.Expression> 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)) diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index dce065a..3a9d7ea 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -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() .CreateLogger(); + var siteCallAuditOptions = _serviceProvider + .GetRequiredService>().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>(); var siteAuditQueue = _serviceProvider .GetRequiredService(); - var siteAuditClient = _serviceProvider - .GetRequiredService(); + // 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() .CreateLogger(); diff --git a/src/ScadaLink.Host/Components/App.razor b/src/ScadaLink.Host/Components/App.razor index ac6d3d2..c426b3e 100644 --- a/src/ScadaLink.Host/Components/App.razor +++ b/src/ScadaLink.Host/Components/App.razor @@ -77,6 +77,7 @@ + diff --git a/src/ScadaLink.ManagementService/AuditEndpoints.cs b/src/ScadaLink.ManagementService/AuditEndpoints.cs index 18217eb..49b50f3 100644 --- a/src/ScadaLink.ManagementService/AuditEndpoints.cs +++ b/src/ScadaLink.ManagementService/AuditEndpoints.cs @@ -367,32 +367,26 @@ public static class AuditEndpoints // ───────────────────────────────────────────────────────────────────── /// - /// Parses the query-string into an . 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 . The + /// channel/kind/status/sourceSiteId dimensions are + /// multi-value: a repeated query param (channel=A&channel=B) 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. /// + /// + /// This endpoint reads the source-site filter from the sourceSiteId + /// query key, whereas the CentralUI export endpoint reads it as site. + /// The divergence is deliberate — each endpoint matches its own CLI / UI URL + /// builder — so do NOT "fix" the two to a single key name. + /// public static AuditLogQueryFilter ParseFilter(IQueryCollection query) { - AuditChannel? channel = null; - if (query.TryGetValue("channel", out var channelValues) - && Enum.TryParse(channelValues.ToString(), ignoreCase: true, out var parsedChannel)) - { - channel = parsedChannel; - } - - AuditKind? kind = null; - if (query.TryGetValue("kind", out var kindValues) - && Enum.TryParse(kindValues.ToString(), ignoreCase: true, out var parsedKind)) - { - kind = parsedKind; - } - - AuditStatus? status = null; - if (query.TryGetValue("status", out var statusValues) - && Enum.TryParse(statusValues.ToString(), ignoreCase: true, out var parsedStatus)) - { - status = parsedStatus; - } + var channels = AuditQueryParamParsers.ParseEnumList(query["channel"]); + var kinds = AuditQueryParamParsers.ParseEnumList(query["kind"]); + var statuses = AuditQueryParamParsers.ParseEnumList(query["status"]); + var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]); Guid? correlationId = null; if (query.TryGetValue("correlationId", out var corrValues) @@ -402,10 +396,10 @@ public static class AuditEndpoints } 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, diff --git a/src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj b/src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj index d8b0e7a..0a46f34 100644 --- a/src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj +++ b/src/ScadaLink.SiteCallAudit/ScadaLink.SiteCallAudit.csproj @@ -13,6 +13,8 @@ + + @@ -22,6 +24,11 @@ project reference is documented here so the actor's scope-per-message GetRequiredService() compiles. --> + + diff --git a/src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs b/src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs index 6eb0d80..e764b68 100644 --- a/src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.SiteCallAudit/ServiceCollectionExtensions.cs @@ -7,33 +7,34 @@ namespace ScadaLink.SiteCallAudit; /// /// /// -/// 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 (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. /// /// /// The repository (ISiteCallAuditRepository) is registered by /// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase, /// so callers (the Host on the central node) must also call that. The actor's -/// Props 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. +/// Props are wired up in Host registration. /// /// public static class ServiceCollectionExtensions { + /// Configuration section bound to . + public const string OptionsSection = "ScadaLink:SiteCallAudit"; + /// - /// 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 + /// binding consumed by the actor's read-side KPI/query handlers. The actor's + /// Props are still constructed inline in Host wiring. /// 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() + .BindConfiguration(OptionsSection); + return services; } } diff --git a/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs b/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs index 7506681..8078060 100644 --- a/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs +++ b/src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs @@ -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; /// /// /// -/// 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). /// /// /// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" — @@ -42,26 +47,47 @@ namespace ScadaLink.SiteCallAudit; /// public class SiteCallAuditActor : ReceiveActor { + /// Maximum page size honoured by a . + private const int MaxPageSize = 200; + private readonly IServiceProvider? _serviceProvider; private readonly ISiteCallAuditRepository? _injectedRepository; + private readonly SiteCallAuditOptions _options; private readonly ILogger _logger; + /// + /// Task 5 (#22): the central→site command transport — the + /// CentralCommunicationActor, which owns the per-site + /// ClusterClient map and routes a to the + /// owning site. Set via 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 + /// outcome, because there + /// is genuinely no route to any site yet. + /// + private IActorRef? _centralCommunication; + /// /// 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 lets a test pin the stuck/KPI + /// windows; when omitted the production defaults apply. /// public SiteCallAuditActor( ISiteCallAuditRepository repository, - ILogger logger) + ILogger logger, + SiteCallAuditOptions? options = null) { ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(logger); _injectedRepository = repository; _logger = logger; + _options = options ?? new SiteCallAuditOptions(); - ReceiveAsync(OnUpsertAsync); + RegisterHandlers(); } /// @@ -73,15 +99,42 @@ public class SiteCallAuditActor : ReceiveActor /// public SiteCallAuditActor( IServiceProvider serviceProvider, + SiteCallAuditOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); _serviceProvider = serviceProvider; + _options = options; _logger = logger; + RegisterHandlers(); + } + + /// + /// 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 Sender + /// before the first await and PipeTo the result back. + /// + private void RegisterHandlers() + { ReceiveAsync(OnUpsertAsync); + Receive(HandleQuery); + Receive(HandleDetail); + Receive(HandleKpi); + Receive(HandlePerSiteKpi); + + // Task 5 (#22): central→site Retry/Discard relay for parked cached calls. + Receive(msg => + { + _centralCommunication = msg.CentralCommunication; + _logger.LogInformation("SiteCallAudit registered central→site communication transport"); + }); + Receive(HandleRetrySiteCall); + Receive(HandleDiscardSiteCall); } /// @@ -137,4 +190,486 @@ public class SiteCallAuditActor : ReceiveActor scope?.Dispose(); } } + + // ── Task 4: read-side (query / detail / KPI) ── + + /// + /// Handles a paginated, filtered query over the SiteCalls table. + /// Builds a + + /// 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. + /// + 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(), + NextAfterCreatedAtUtc: null, + NextAfterId: null)); + } + + private async Task 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(); + } + } + + /// + /// Handles a full-detail query for a single cached call — backs the report + /// detail modal. A missing row yields Success=false with a "not + /// found" message; a repository fault yields Success=false with the + /// fault message. + /// + 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 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(); + } + } + + /// + /// Handles a global KPI snapshot request, deriving the stuck cutoff from + /// and the + /// failed/delivered interval bound from . + /// + 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 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(); + } + } + + /// + /// Handles a per-source-site KPI request, using the same stuck cutoff and + /// interval bound as . + /// + 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())); + } + + private async Task 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 ── + + /// + /// 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 + /// SiteCalls mirror row. It wraps a + /// in a addressed to SourceSite, Asks the + /// CentralCommunicationActor (which routes it over the per-site + /// ClusterClient), and maps the site's + /// — or an Ask timeout — onto a + /// . A timeout / no-route is reported as + /// the distinct outcome, + /// not a generic failure, so the Central UI can tell "site offline" from + /// "operation failed". + /// + 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(envelope, _options.RelayTimeout) + .PipeTo( + sender, + success: ack => MapRetryResponse(request.CorrelationId, ack), + failure: ex => MapRetryFailure(request.CorrelationId, request.SourceSite, ex)); + } + + /// + /// Relays an operator Discard of a parked cached call to its owning site. + /// Mirrors — see that method for the + /// source-of-truth and site-unreachable rationale. + /// + 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(envelope, _options.RelayTimeout) + .PipeTo( + sender, + success: ack => MapDiscardResponse(request.CorrelationId, ack), + failure: ex => MapDiscardFailure(request.CorrelationId, request.SourceSite, ex)); + } + + /// + /// Maps the site's for a Retry onto a + /// : an applied action is + /// ; a clean no-op + /// (Applied=false, no error) is ; + /// an ack carrying an error is + /// — in every case the site WAS reached. + /// + 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); + } + + /// + /// Classifies a site ack: Applied=true → applied; Applied=false + /// with no error → the site definitively had nothing parked; Applied=false + /// with an error → the site could not apply the action. + /// + 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"), + }; + } + + /// Shared "site unreachable" detail text for both relay directions. + 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); + } + + /// + /// Resolves an 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 . + /// + private (IServiceScope? Scope, ISiteCallAuditRepository Repository) ResolveRepository() + { + if (_injectedRepository is not null) + { + return (null, _injectedRepository); + } + + var scope = _serviceProvider!.CreateScope(); + return (scope, scope.ServiceProvider.GetRequiredService()); + } + + /// + /// A cached call counts as stuck when it is still non-terminal and was + /// created before . Non-terminal is keyed off + /// being null — the + /// SiteCalls operational mirror stores AuditStatus-derived + /// status strings (Attempted/Delivered/Parked/...), not + /// the tracking-lifecycle Pending/Retrying names the spec's + /// KPI section uses, so there is no status string that means "buffered". + /// TerminalAtUtc is the entity's own active/terminal discriminator + /// and is consistent with the repository KPI counts and + /// PurgeTerminalAsync. + /// + 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); + } + + /// + /// Treats an empty/whitespace filter string as "no constraint" — the + /// repository's interprets null as + /// a no-op predicate, so a blank UI filter must collapse to null. + /// + private static string? NullIfBlank(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value; + } } + +/// +/// Registers the central→site command transport (the CentralCommunicationActor) +/// with the 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 +/// and ScadaLink.Commons has no Akka reference — +/// the same rationale as RegisterAuditIngest. +/// +public sealed record RegisterCentralCommunication(IActorRef CentralCommunication); diff --git a/src/ScadaLink.SiteCallAudit/SiteCallAuditOptions.cs b/src/ScadaLink.SiteCallAudit/SiteCallAuditOptions.cs new file mode 100644 index 0000000..d29b71d --- /dev/null +++ b/src/ScadaLink.SiteCallAudit/SiteCallAuditOptions.cs @@ -0,0 +1,47 @@ +namespace ScadaLink.SiteCallAudit; + +/// +/// Configuration options for the Site Call Audit (#22) read-side: stuck-call +/// detection and KPI windowing. Mirrors the KPI-relevant subset of +/// NotificationOutboxOptions — 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. +/// +public class SiteCallAuditOptions +{ + /// + /// Age past which a non-terminal cached call (Pending/Retrying) + /// is considered stuck. Display-only — surfaced as the Stuck KPI and a row + /// badge, with no escalation. Default 10 minutes, matching + /// NotificationOutboxOptions.StuckAgeThreshold. + /// + public TimeSpan StuckAgeThreshold { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// Trailing window used to compute the delivered- and failed-last-interval + /// throughput KPIs. Default 1 minute, matching + /// NotificationOutboxOptions.DeliveredKpiWindow. + /// + public TimeSpan KpiInterval { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Task 5 (#22): Ask timeout for the central→site Retry/Discard relay. When + /// the owning site does not ack a RetryParkedOperation / + /// DiscardParkedOperation within this window — site offline, no + /// ClusterClient route, or central buffering deliberately absent — the relay + /// reports a SiteUnreachable 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. + /// + /// Ordering invariant: RelayTimeout must stay below + /// CommunicationOptions.QueryTimeout (default 30s), the timeout the + /// outer CommunicationService.RetrySiteCallAsync/DiscardSiteCallAsync + /// Ask of the SiteCallAuditActor uses. The outer Ask must outlive this + /// inner site relay Ask so the inner relay times out first and yields the + /// distinct SiteUnreachable 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. + /// + /// + public TimeSpan RelayTimeout { get; set; } = TimeSpan.FromSeconds(10); +} diff --git a/src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs b/src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs index 0f922cd..8d2b8ed 100644 --- a/src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs +++ b/src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs @@ -24,6 +24,13 @@ public class ParkedMessageHandlerActor : ReceiveActor Receive(HandleQuery); Receive(HandleRetry); Receive(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(HandleRetryParkedOperation); + Receive(HandleDiscardParkedOperation); } private void HandleQuery(ParkedMessageQueryRequest msg) @@ -90,6 +97,46 @@ public class ParkedMessageHandlerActor : ReceiveActor msg.CorrelationId, false, ex.GetBaseException().Message)); } + /// + /// 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 + /// — which only + /// touches rows that are actually Parked (a non-parked or unknown + /// operation yields false, a safe no-op). Central never mutates the + /// central SiteCalls mirror; the reset row's corrected state flows + /// back via the normal cached-call telemetry path. + /// + 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)); + } + + /// + /// Task 5 (#22): executes a central-relayed Discard of a parked cached call. + /// Mirrors ; Discard removes the + /// parked S&F buffer row (only when it is actually Parked). + /// + 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)) diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs index f61d1cd..7e3d1ea 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorCombinedTelemetryTests.cs @@ -356,6 +356,12 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< _inner.QueryAsync(filter, paging, ct); public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => _inner.PurgeTerminalAsync(olderThanUtc, ct); + public Task ComputeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + _inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct); + public Task> ComputePerSiteKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); } /// @@ -387,5 +393,11 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture< _inner.QueryAsync(filter, paging, ct); public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => _inner.PurgeTerminalAsync(olderThanUtc, ct); + public Task ComputeKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + _inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct); + public Task> ComputePerSiteKpisAsync( + DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) => + _inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct); } } diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs index 1d67ed7..63d8574 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/CombinedTelemetryIdempotencyTests.cs @@ -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(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. /// /// - /// Uses the shared for the audit half; - /// the SiteCall DTO is decoded inline because the AuditLog mapper does not - /// (and should not) know about — the - /// production gRPC server (Bundle D) uses the same inline shape. + /// Uses the shared for the audit half + /// and for the SiteCall half — the same + /// canonical mappers the production SiteStreamGrpcServer uses. /// public async Task IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct) { @@ -132,8 +129,8 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient var entries = new List(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; } - - /// - /// Mirrors SiteStreamGrpcServer.MapSiteCallFromDto — keep the two in - /// sync. The placeholder stamped here - /// is overwritten by the central ingest actor inside the dual-write - /// transaction, so the value sent on the wire is informational only. - /// - 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, - }; } diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs index cfc5bcf..5560afa 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/NotifyDispatcherAuditTrailTests.cs @@ -241,7 +241,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture +/// Tests for — the production +/// binding wired by the Host for site +/// roles. The client maps the proto-DTO batches produced by +/// into the Akka +/// / +/// messages, Asks the site's SiteCommunicationActor (which forwards over +/// ClusterClient to central), and maps the reply back into an +/// . +/// +/// +/// A stands in for the SiteCommunicationActor: +/// it lets the tests assert the exact command shape AND drive the reply (or +/// withhold one to exercise the Ask-timeout path). +/// +public class ClusterClientSiteAuditClientTests : TestKit +{ + /// Short Ask timeout so the timeout test completes quickly. + private static readonly TimeSpan AskTimeout = TimeSpan.FromMilliseconds(500); + + private static AuditEvent NewEvent(Guid? id = null) => new() + { + EventId = id ?? Guid.NewGuid(), + OccurredAtUtc = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + SourceSiteId = "site-1", + ForwardState = AuditForwardState.Pending, + }; + + private static AuditEventBatch BatchOf(IEnumerable events) + { + var batch = new AuditEventBatch(); + foreach (var e in events) + { + batch.Events.Add(AuditEventDtoMapper.ToDto(e)); + } + return batch; + } + + private static SiteCallOperationalDto NewOperationalDto() => new() + { + TrackedOperationId = Guid.NewGuid().ToString(), + Channel = "ApiOutbound", + Target = "ext-system-1", + SourceSite = "site-1", + Status = "Submitted", + RetryCount = 0, + LastError = string.Empty, + CreatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)), + UpdatedAtUtc = Timestamp.FromDateTime(new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc)), + }; + + [Fact] + public async Task IngestAuditEventsAsync_FullAck_MapsAllAcceptedIdsOntoAck() + { + var probe = CreateTestProbe(); + var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout); + + var events = Enumerable.Range(0, 3).Select(_ => NewEvent()).ToList(); + var batch = BatchOf(events); + + var task = sut.IngestAuditEventsAsync(batch, CancellationToken.None); + + // The probe receives exactly one IngestAuditEventsCommand carrying the + // batch's events; it replies with every EventId accepted. + var cmd = probe.ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Equal(3, cmd.Events.Count); + Assert.Equal( + events.Select(e => e.EventId).ToHashSet(), + cmd.Events.Select(e => e.EventId).ToHashSet()); + probe.Reply(new IngestAuditEventsReply(events.Select(e => e.EventId).ToList())); + + var ack = await task; + + Assert.Equal( + events.Select(e => e.EventId.ToString()).ToHashSet(), + ack.AcceptedEventIds.ToHashSet()); + } + + [Fact] + public async Task IngestAuditEventsAsync_PartialAck_OnlyAcceptedIdsAppearOnAck() + { + var probe = CreateTestProbe(); + var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout); + + var events = Enumerable.Range(0, 5).Select(_ => NewEvent()).ToList(); + var accepted = events.Take(3).Select(e => e.EventId).ToList(); + + var task = sut.IngestAuditEventsAsync(BatchOf(events), CancellationToken.None); + + probe.ExpectMsg(TimeSpan.FromSeconds(3)); + probe.Reply(new IngestAuditEventsReply(accepted)); + + var ack = await task; + + Assert.Equal(3, ack.AcceptedEventIds.Count); + Assert.Equal( + accepted.Select(id => id.ToString()).ToHashSet(), + ack.AcceptedEventIds.ToHashSet()); + } + + [Fact] + public async Task IngestAuditEventsAsync_AskTimeout_Throws_SoDrainLoopKeepsRowsPending() + { + var probe = CreateTestProbe(); + var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout); + + var batch = BatchOf(new[] { NewEvent() }); + + // The probe receives the command but never replies — the Ask times out. + // The contract: a timeout MUST surface as a thrown exception so the + // SiteAuditTelemetryActor drain loop leaves the rows Pending. + var task = sut.IngestAuditEventsAsync(batch, CancellationToken.None); + probe.ExpectMsg(TimeSpan.FromSeconds(3)); + + await Assert.ThrowsAnyAsync(() => task); + } + + [Fact] + public async Task IngestAuditEventsAsync_FaultedReply_Throws() + { + var probe = CreateTestProbe(); + var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout); + + var task = sut.IngestAuditEventsAsync(BatchOf(new[] { NewEvent() }), CancellationToken.None); + probe.ExpectMsg(TimeSpan.FromSeconds(3)); + + // A Status.Failure from central (Task 1: central does not swallow an + // ingest fault into an empty ack) must propagate as a thrown exception. + probe.Reply(new Status.Failure(new InvalidOperationException("central ingest faulted"))); + + await Assert.ThrowsAnyAsync(() => task); + } + + [Fact] + public async Task IngestCachedTelemetryAsync_RoutesCommand_AndMapsReply() + { + var probe = CreateTestProbe(); + var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout); + + var events = Enumerable.Range(0, 2).Select(_ => NewEvent()).ToList(); + var batch = new CachedTelemetryBatch(); + foreach (var e in events) + { + batch.Packets.Add(new CachedTelemetryPacket + { + AuditEvent = AuditEventDtoMapper.ToDto(e), + Operational = NewOperationalDto(), + }); + } + + var task = sut.IngestCachedTelemetryAsync(batch, CancellationToken.None); + + // The probe receives an IngestCachedTelemetryCommand (NOT an + // IngestAuditEventsCommand) with one entry per packet. + var cmd = probe.ExpectMsg(TimeSpan.FromSeconds(3)); + Assert.Equal(2, cmd.Entries.Count); + Assert.Equal( + events.Select(e => e.EventId).ToHashSet(), + cmd.Entries.Select(en => en.Audit.EventId).ToHashSet()); + probe.Reply(new IngestCachedTelemetryReply(events.Select(e => e.EventId).ToList())); + + var ack = await task; + + Assert.Equal( + events.Select(e => e.EventId.ToString()).ToHashSet(), + ack.AcceptedEventIds.ToHashSet()); + } + + [Fact] + public async Task IngestCachedTelemetryAsync_AskTimeout_Throws() + { + var probe = CreateTestProbe(); + var sut = new ClusterClientSiteAuditClient(probe.Ref, AskTimeout); + + var batch = new CachedTelemetryBatch(); + batch.Packets.Add(new CachedTelemetryPacket + { + AuditEvent = AuditEventDtoMapper.ToDto(NewEvent()), + Operational = NewOperationalDto(), + }); + + var task = sut.IngestCachedTelemetryAsync(batch, CancellationToken.None); + probe.ExpectMsg(TimeSpan.FromSeconds(3)); + + await Assert.ThrowsAnyAsync(() => task); + } +} diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs index cd3349a..04fc622 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs @@ -63,8 +63,8 @@ public class AuditExportCommandTests Until = "2026-05-20T12:00:00Z", Format = "jsonl", Output = "/tmp/x", - Channel = "Notification", - Site = "site-9", + Channel = new[] { "Notification" }, + Site = new[] { "site-9" }, }; var qs = AuditExportHelpers.BuildQueryString(args, now); var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); @@ -76,6 +76,90 @@ public class AuditExportCommandTests Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); } + [Fact] + public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var args = new AuditExportArgs + { + Since = "1h", + Until = "2026-05-20T12:00:00Z", + Format = "csv", + Output = "/tmp/x", + Channel = new[] { "ApiOutbound", "DbOutbound" }, + Kind = new[] { "ApiCall", "DbWrite" }, + Status = new[] { "Failed", "Parked" }, + Site = new[] { "site-1", "site-2" }, + }; + var qs = AuditExportHelpers.BuildQueryString(args, now); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + + Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel")); + Assert.Equal(new[] { "ApiCall", "DbWrite" }, parsed.GetValues("kind")); + Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status")); + Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId")); + } + + [Fact] + public void BuildQueryString_OmitsUnsetMultiValueFilters() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var args = new AuditExportArgs + { + Since = "1h", + Until = "0h", + Format = "csv", + Output = "/tmp/x", + }; + var qs = AuditExportHelpers.BuildQueryString(args, now); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + + Assert.Null(parsed["channel"]); + Assert.Null(parsed["kind"]); + Assert.Null(parsed["status"]); + Assert.Null(parsed["sourceSiteId"]); + } + + [Fact] + public void Export_MultipleChannelValues_SingleToken_AreAccepted() + { + // AllowMultipleArgumentsPerToken: --channel A B parses as two values. + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "export", "--since", "1h", "--until", "0h", + "--format", "csv", "--output", "/tmp/out.csv", + "--channel", "ApiOutbound", "DbOutbound", + }); + Assert.Empty(parse.Errors); + } + + [Fact] + public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted() + { + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "export", "--since", "1h", "--until", "0h", + "--format", "csv", "--output", "/tmp/out.csv", + "--channel", "ApiOutbound", "--channel", "Notification", + }); + Assert.Empty(parse.Errors); + } + + [Fact] + public void Export_MultiValueChannel_WithOneInvalidName_FailsFast() + { + // AcceptOnlyFromAmong validates EACH value of the multi-value option. + var root = AuditCommandTestHarness.BuildRoot(); + var (exit, _, err) = AuditCommandTestHarness.Invoke( + root, "audit", "export", "--since", "1h", "--until", "0h", + "--format", "csv", "--output", "/tmp/out.csv", + "--channel", "ApiOutbound", "OutboundApi"); + Assert.NotEqual(0, exit); + Assert.NotEqual("", err); + } + // ---- Streaming export to file ----------------------------------------- private sealed class BodyHandler : HttpMessageHandler diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs index b3840c5..3df692b 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -58,10 +58,10 @@ public class AuditQueryCommandTests { Since = "1h", Until = "2026-05-20T12:00:00Z", - Channel = "ApiOutbound", - Kind = "ApiCallCached", - Status = "Delivered", - Site = "site-1", + Channel = new[] { "ApiOutbound" }, + Kind = new[] { "ApiCallCached" }, + Status = new[] { "Delivered" }, + Site = new[] { "site-1" }, Target = "weather-api", Actor = "multi-role", CorrelationId = "abc-123", @@ -76,7 +76,7 @@ public class AuditQueryCommandTests Assert.Equal("ApiCallCached", parsed["kind"]); Assert.Equal("Delivered", parsed["status"]); Assert.Equal("site-1", parsed["sourceSiteId"]); - // --instance was dropped: AuditLogQueryFilter has no instance column. + // The CLI audit query has no --instance flag, so no instance param is emitted. Assert.Null(parsed["instance"]); Assert.Equal("weather-api", parsed["target"]); Assert.Equal("multi-role", parsed["actor"]); @@ -96,6 +96,43 @@ public class AuditQueryCommandTests Assert.Equal("Failed", parsed["status"]); } + [Fact] + public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue() + { + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs + { + Channel = new[] { "ApiOutbound", "DbOutbound" }, + Status = new[] { "Failed", "Parked" }, + Site = new[] { "site-1", "site-2" }, + }; + + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + + Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel")); + Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status")); + Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId")); + } + + [Fact] + public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues() + { + // --errors-only stays a single-status override: it pins status=Failed and + // supersedes any explicit (multi-value) --status selection. + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs + { + ErrorsOnly = true, + Status = new[] { "Delivered", "Parked" }, + }; + + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + + Assert.Equal(new[] { "Failed" }, parsed.GetValues("status")); + } + [Fact] public void BuildQueryString_Cursor_AppendsAfterParameters() { @@ -254,6 +291,38 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + [Fact] + public void Query_MultipleChannelValues_SingleToken_AreAccepted() + { + // AllowMultipleArgumentsPerToken: --channel A B parses as two values. + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "DbOutbound" }); + Assert.Empty(parse.Errors); + } + + [Fact] + public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted() + { + // --channel A --channel B parses as two values. + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "query", "--channel", "ApiOutbound", "--channel", "Notification", + }); + Assert.Empty(parse.Errors); + } + + [Fact] + public void Query_MultiValueChannel_WithOneInvalidName_FailsFast() + { + // AcceptOnlyFromAmong validates EACH value of the multi-value option. + var root = AuditCommandTestHarness.BuildRoot(); + var (exit, _, err) = AuditCommandTestHarness.Invoke( + root, "audit", "query", "--channel", "ApiOutbound", "OutboundApi"); + Assert.NotEqual(0, exit); + Assert.NotEqual("", err); + } + [Fact] public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit() { diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs new file mode 100644 index 0000000..36a9dba --- /dev/null +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs @@ -0,0 +1,281 @@ +using Microsoft.Playwright; +using Xunit; + +namespace ScadaLink.CentralUI.PlaywrightTests.Audit; + +/// +/// End-to-end coverage for the Audit Log results-grid column UX (#23 +/// follow-ups Task 10): drag-to-resize and drag-to-reorder columns, with the +/// chosen widths + order persisted in the browser's sessionStorage. +/// +/// +/// The drag interaction is browser-side (wwwroot/js/audit-grid.js), so +/// Playwright — not bUnit — is the right tool: bUnit cannot drive the native +/// HTML5 drag-and-drop or pointer-capture resize. Each test seeds one +/// AuditLog row via so the grid has a +/// header row to act on, then best-effort deletes it. +/// +/// +/// +/// The DB-seeding tests are + Skip.IfNot: +/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed), +/// matching the established idiom. +/// +/// +[Collection("Playwright")] +public class AuditGridColumnTests +{ + private const string AuditLogUrl = "/audit/log"; + + /// Skip reason shared by the DB-seeding tests when MSSQL is down. + private const string DbUnavailableSkipReason = + "AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " + + "or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string."; + + private readonly PlaywrightFixture _fixture; + + public AuditGridColumnTests(PlaywrightFixture fixture) + { + _fixture = fixture; + } + + /// + /// Seeds one audit row, opens the Audit Log page, and clicks Apply so the + /// results grid renders a header row the column tests can act on. + /// + private async Task OpenGridWithSeededRowAsync(string targetPrefix, Guid eventId) + { + await AuditDataSeeder.InsertAuditEventAsync( + eventId: eventId, + occurredAtUtc: DateTime.UtcNow, + channel: "ApiOutbound", + kind: "ApiCall", + status: "Delivered", + target: targetPrefix + "endpoint", + httpStatus: 200, + durationMs: 25); + + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{AuditLogUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Apply with no chips — the default LastHour range matches the fresh row. + await page.Locator("[data-test='filter-apply']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var row = page.Locator($"[data-test='grid-row-{eventId}']"); + await Assertions.Expect(row).ToBeVisibleAsync(); + return page; + } + + /// Pixel width of a header cell, measured from its bounding box. + private static async Task HeaderWidthAsync(IPage page, string columnKey) + { + var box = await page.Locator($"[data-col-key='{columnKey}']").BoundingBoxAsync(); + Assert.NotNull(box); + return box!.Width; + } + + /// The ordered list of column keys as currently rendered in the header. + private static async Task> HeaderOrderAsync(IPage page) + { + return await page.Locator("thead th[data-col-key]") + .EvaluateAllAsync("els => els.map(e => e.getAttribute('data-col-key'))"); + } + + /// + /// Polls until has been written to + /// sessionStorage. The grid persists a resize/reorder + /// asynchronously — the browser-side drag fires a fire-and-forget + /// JS→.NET invoke (OnColumnResized/OnColumnReordered), and + /// the .NET handler then round-trips back through JS interop to write + /// sessionStorage. A bare getItem immediately after the drag + /// races that round-trip; this waits for the key to actually land. + /// + private static async Task WaitForStorageKeyAsync(IPage page, string storageKey) + { + await page.WaitForFunctionAsync( + "key => sessionStorage.getItem(key) !== null", storageKey); + } + + /// + /// Polls until the header's first column key equals . + /// A drag-to-reorder re-renders the header asynchronously (the JS→.NET + /// OnColumnReordered invoke is fire-and-forget), so reading the + /// header order synchronously after DragToAsync can observe the + /// pre-reorder layout. This waits for the re-render to settle. + /// + private static async Task WaitForFirstColumnAsync(IPage page, string expectedFirstKey) + { + await page.WaitForFunctionAsync( + "key => { var th = document.querySelector('thead th[data-col-key]'); " + + "return th && th.getAttribute('data-col-key') === key; }", + expectedFirstKey); + } + + [SkippableFact] + public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload() + { + Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/grid-resize/{runId}/"; + var eventId = Guid.NewGuid(); + + try + { + var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId); + + const string columnKey = "Target"; + var before = await HeaderWidthAsync(page, columnKey); + + // Drag the resize handle on the column's right edge 120px to the + // right. The handle is a thin strip; grab its centre and drag. + var handle = page.Locator($"[data-test='col-resize-{columnKey}']"); + var handleBox = await handle.BoundingBoxAsync(); + Assert.NotNull(handleBox); + var startX = handleBox!.X + handleBox.Width / 2; + var startY = handleBox.Y + handleBox.Height / 2; + + await page.Mouse.MoveAsync(startX, startY); + await page.Mouse.DownAsync(); + await page.Mouse.MoveAsync(startX + 120, startY, new MouseMoveOptions { Steps = 8 }); + await page.Mouse.UpAsync(); + + var after = await HeaderWidthAsync(page, columnKey); + Assert.True(after > before + 40, + $"Expected the {columnKey} column to widen after the resize drag (before={before}, after={after})."); + + // Reload: the persisted width is restored from sessionStorage. + await page.ReloadAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await page.Locator("[data-test='filter-apply']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var afterReload = await HeaderWidthAsync(page, columnKey); + // Allow a small tolerance for sub-pixel layout rounding. + Assert.True(Math.Abs(afterReload - after) < 8, + $"Expected the resized width to survive a reload (after={after}, afterReload={afterReload})."); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + + [SkippableFact] + public async Task ReorderDrag_MovesColumn_AndSurvivesReload() + { + Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/grid-reorder/{runId}/"; + var eventId = Guid.NewGuid(); + + try + { + var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId); + + var initialOrder = await HeaderOrderAsync(page); + // Default order opens with OccurredAtUtc first, Status fifth. + Assert.Equal("OccurredAtUtc", initialOrder[0]); + Assert.Contains("Status", initialOrder); + + // Drag the Status header onto the OccurredAtUtc header — Status + // should move into the leading slot. + var source = page.Locator("[data-col-key='Status']"); + var target = page.Locator("[data-col-key='OccurredAtUtc']"); + await source.DragToAsync(target); + // The reorder re-renders the header asynchronously (fire-and-forget + // JS→.NET invoke); wait for it to settle before reading the order. + await WaitForFirstColumnAsync(page, "Status"); + + var afterOrder = await HeaderOrderAsync(page); + Assert.Equal("Status", afterOrder[0]); + Assert.True(afterOrder.ToList().IndexOf("Status") < afterOrder.ToList().IndexOf("OccurredAtUtc"), + "Expected Status to be reordered ahead of OccurredAtUtc."); + + // Reload: the persisted order is restored from sessionStorage on + // the grid's first render — wait for the header to reflect it. + await page.ReloadAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await page.Locator("[data-test='filter-apply']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await WaitForFirstColumnAsync(page, "Status"); + + var afterReload = await HeaderOrderAsync(page); + Assert.Equal("Status", afterReload[0]); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + + [SkippableFact] + public async Task ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage() + { + Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/grid-persist/{runId}/"; + var eventId = Guid.NewGuid(); + + try + { + var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId); + + // Reorder then resize, then confirm sessionStorage carries both. + await page.Locator("[data-col-key='Status']") + .DragToAsync(page.Locator("[data-col-key='OccurredAtUtc']")); + // Wait for the reorder re-render to settle before measuring the + // resize handle, so the handle's bounding box is read off the + // post-reorder layout. + await WaitForFirstColumnAsync(page, "Status"); + + var handle = page.Locator("[data-test='col-resize-Target']"); + var handleBox = await handle.BoundingBoxAsync(); + Assert.NotNull(handleBox); + var startX = handleBox!.X + handleBox.Width / 2; + var startY = handleBox.Y + handleBox.Height / 2; + await page.Mouse.MoveAsync(startX, startY); + await page.Mouse.DownAsync(); + await page.Mouse.MoveAsync(startX + 90, startY, new MouseMoveOptions { Steps = 6 }); + await page.Mouse.UpAsync(); + + // Both keys are written under the auditGrid: namespace — but the + // write is asynchronous: pointer-up fires a fire-and-forget + // OnColumnResized/OnColumnReordered JS→.NET invoke, and the .NET + // handler then round-trips back through JS interop to call + // auditGrid.save. Reading sessionStorage synchronously right after + // Mouse.UpAsync races that round-trip, so poll for both keys to + // land before asserting on them. + await WaitForStorageKeyAsync(page, "auditGrid:columnOrder"); + await WaitForStorageKeyAsync(page, "auditGrid:columnWidths"); + var orderJson = await page.EvaluateAsync( + "() => sessionStorage.getItem('auditGrid:columnOrder')"); + var widthsJson = await page.EvaluateAsync( + "() => sessionStorage.getItem('auditGrid:columnWidths')"); + Assert.NotNull(orderJson); + Assert.Contains("Status", orderJson!); + Assert.NotNull(widthsJson); + Assert.Contains("Target", widthsJson!); + + // After a reload the restored grid reflects the stored order. The + // restore happens on the grid's first render (LoadPersistedState → + // StateHasChanged), so wait for the header to reflect it. + await page.ReloadAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await page.Locator("[data-test='filter-apply']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await WaitForFirstColumnAsync(page, "Status"); + + var restoredOrder = await HeaderOrderAsync(page); + Assert.Equal("Status", restoredOrder[0]); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } +} diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj b/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj index dd87843..e89e456 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj @@ -15,6 +15,13 @@ + + diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs new file mode 100644 index 0000000..b05d3f0 --- /dev/null +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallDataSeeder.cs @@ -0,0 +1,135 @@ +using Microsoft.Data.SqlClient; + +namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls; + +/// +/// Direct-SQL seeding helper for the Site Calls page Playwright E2E tests +/// (Site Call Audit #22, follow-ups Task 6). +/// +/// +/// The Site Calls page reads the central SiteCalls table through the +/// SiteCallAuditActor, which is a pure read-from-table mirror — so a row +/// INSERTed directly into SiteCalls surfaces on the page exactly as a +/// telemetry-ingested row would. Mirrors : +/// each test inserts its own rows at setup and best-effort deletes them at +/// teardown, keeping the suite self-contained without touching +/// infra/mssql/seed-config.sql. +/// +/// +/// +/// Rows are tagged with a unique Target prefix derived from the test name +/// + a GUID so the teardown DELETE never touches rows the cluster itself +/// produced. CreatedAtUtc/UpdatedAtUtc are pinned to "now" so the +/// page's default (unconstrained) query window sees the row. +/// +/// +internal static class SiteCallDataSeeder +{ + private const string DefaultConnectionString = + "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5"; + + private const string EnvVar = "SCADALINK_PLAYWRIGHT_DB"; + + /// + /// Connection string for the running cluster's configuration DB. Resolved + /// from SCADALINK_PLAYWRIGHT_DB when set, otherwise the local docker + /// dev defaults. + /// + public static string ConnectionString + { + get + { + var fromEnv = Environment.GetEnvironmentVariable(EnvVar); + return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv; + } + } + + /// + /// Inserts a single row into the central SiteCalls table. Optional + /// fields are nullable so a test can shape the row to the status/channel it + /// needs for its grid assertions. TrackedOperationId is stored as the + /// 36-character GUID string the entity mapping expects. + /// + public static async Task InsertSiteCallAsync( + Guid trackedOperationId, + string channel, + string target, + string sourceSite, + string status, + int retryCount, + DateTime createdAtUtc, + DateTime updatedAtUtc, + string? lastError = null, + int? httpStatus = null, + DateTime? terminalAtUtc = null, + CancellationToken ct = default) + { + const string sql = @" +INSERT INTO [SiteCalls] +([TrackedOperationId], [Channel], [Target], [SourceSite], [Status], [RetryCount], + [LastError], [HttpStatus], [CreatedAtUtc], [UpdatedAtUtc], [TerminalAtUtc], [IngestedAtUtc]) +VALUES +(@id, @channel, @target, @sourceSite, @status, @retryCount, + @lastError, @httpStatus, @createdAtUtc, @updatedAtUtc, @terminalAtUtc, SYSUTCDATETIME());"; + + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@id", trackedOperationId.ToString()); + cmd.Parameters.AddWithValue("@channel", channel); + cmd.Parameters.AddWithValue("@target", target); + cmd.Parameters.AddWithValue("@sourceSite", sourceSite); + cmd.Parameters.AddWithValue("@status", status); + cmd.Parameters.AddWithValue("@retryCount", retryCount); + cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value); + cmd.Parameters.AddWithValue("@httpStatus", (object?)httpStatus ?? DBNull.Value); + cmd.Parameters.AddWithValue("@createdAtUtc", createdAtUtc); + cmd.Parameters.AddWithValue("@updatedAtUtc", updatedAtUtc); + cmd.Parameters.AddWithValue("@terminalAtUtc", (object?)terminalAtUtc ?? DBNull.Value); + + await cmd.ExecuteNonQueryAsync(ct); + } + + /// + /// Best-effort cleanup. Deletes every SiteCalls row whose Target + /// starts with . Swallows all errors — the + /// prefix carries a per-run GUID so the rows are unique to this test run. + /// + public static async Task DeleteByTargetPrefixAsync(string targetPrefix, CancellationToken ct = default) + { + try + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "DELETE FROM [SiteCalls] WHERE [Target] LIKE @prefix"; + cmd.Parameters.AddWithValue("@prefix", targetPrefix + "%"); + await cmd.ExecuteNonQueryAsync(ct); + } + catch + { + // Best-effort — the prefix carries a GUID so the rows are unique to + // this test run and won't collide on the next pass. + } + } + + /// + /// Probe whether the configuration DB is reachable. Tests gate their per-test + /// setup on this so a downed cluster surfaces a clear message rather than an + /// opaque . + /// + public static async Task IsAvailableAsync(CancellationToken ct = default) + { + try + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(ct); + return true; + } + catch + { + return false; + } + } +} diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs new file mode 100644 index 0000000..303aa90 --- /dev/null +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs @@ -0,0 +1,333 @@ +using Microsoft.Playwright; +using Xunit; + +namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls; + +/// +/// End-to-end coverage for the central Site Calls page (Site Call Audit #22, +/// follow-ups Task 6). +/// +/// +/// Each test seeds its own SiteCalls rows directly into the running +/// cluster's configuration database via , +/// exercises the UI through Playwright, then best-effort deletes the rows by +/// their Target prefix. The Site Calls page reads the SiteCalls +/// table through the SiteCallAuditActor (a pure read-from-table mirror), +/// so a directly-INSERTed row surfaces exactly as a telemetry-ingested row +/// would — the same seeding model the Audit Log E2E tests use. The pattern +/// keeps each test self-contained without touching +/// infra/mssql/seed-config.sql. +/// +/// +/// +/// Scenarios covered (per the Task 6 brief): +/// +/// PageLoads — the page renders for a Deployment-role user. +/// FilterNarrowing — a channel filter narrows the results grid. +/// DrillIn — the "View audit history" link deep-links into the +/// Audit Log pre-filtered to the call's TrackedOperationId. +/// RetryDiscardVisibility — Retry/Discard appear only on Parked +/// rows, never on Failed (or other) rows. +/// RetryClickThrough — clicking Retry on a Parked row confirms +/// the dialog, relays to the owning site, and surfaces an outcome toast. +/// +/// +/// +/// +/// The DB-seeding tests are + Skip.IfNot: +/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed), +/// matching the established ScadaLink.ConfigurationDatabase.Tests idiom. +/// +/// +[Collection("Playwright")] +public class SiteCallsPageTests +{ + private const string SiteCallsUrl = "/site-calls/report"; + + private readonly PlaywrightFixture _fixture; + + public SiteCallsPageTests(PlaywrightFixture fixture) + { + _fixture = fixture; + } + + /// + /// Sets the Target-keyword search box and commits the value to the server + /// as its own discrete circuit message before the caller clicks Query. + /// + /// The #sc-search input is a Blazor @bind + /// (commit-on-change): only fires + /// input events, and the change that actually updates + /// _targetFilter on the server fires on blur. The original test + /// relied on the Query ClickAsync itself to blur the field — that + /// makes the change (blur) and the click a single, near- + /// simultaneous gesture and races them over the SignalR circuit: when the + /// click is processed before the change has updated + /// _targetFilter, Search() runs with a stale (empty) keyword + /// and the grid returns unfiltered rows. + /// + /// + /// raises the change as a + /// fully-awaited action of its own, so its circuit message is enqueued and + /// sent before the later Query ClickAsync's message. The SignalR + /// connection delivers messages in send order and the Blazor circuit + /// processes them sequentially, so _targetFilter is guaranteed + /// committed before Search() runs — the two are no longer one + /// racing gesture. + /// + /// + private static async Task SetSearchKeywordAsync(IPage page, string keyword) + { + var search = page.Locator("#sc-search"); + await search.FillAsync(keyword); + // Commit the @bind as a discrete change event — not a blur side effect + // of the subsequent Query click. + await search.DispatchEventAsync("change"); + } + + [Fact] + public async Task PageLoads_ForDeploymentUser() + { + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + Assert.Contains(SiteCallsUrl, page.Url); + await Assertions.Expect(page.Locator("h4:has-text('Site Calls')")).ToBeVisibleAsync(); + // The filter card's Query button is the page's primary action. + await Assertions.Expect(page.Locator("[data-test='site-calls-query']")).ToBeVisibleAsync(); + } + + /// Skip reason shared by the DB-seeding tests when MSSQL is down. + private const string DbUnavailableSkipReason = + "SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " + + "or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string."; + + [SkippableFact] + public async Task FilterNarrowing_ChannelFilterShrinksGrid() + { + Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/sc-filter/{runId}/"; + var apiId = Guid.NewGuid(); + var dbId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + // One ApiOutbound row, one DbOutbound row — distinct Targets. + await SiteCallDataSeeder.InsertSiteCallAsync( + trackedOperationId: apiId, channel: "ApiOutbound", target: targetPrefix + "api", + sourceSite: "plant-a", status: "Delivered", retryCount: 0, + createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now); + await SiteCallDataSeeder.InsertSiteCallAsync( + trackedOperationId: dbId, channel: "DbOutbound", target: targetPrefix + "db", + sourceSite: "plant-a", status: "Delivered", retryCount: 0, + createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now); + + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Unfiltered query: both seeded rows appear (the Target keyword scopes + // to this run so unrelated cluster rows do not interfere). + await SetSearchKeywordAsync(page, targetPrefix + "api"); + await page.Locator("[data-test='site-calls-query']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Only the ApiOutbound row matches the exact target keyword. The + // grid filters with an exact Target match, so the db row must be + // absent — use the retrying ToHaveCount assertion so the negative + // check waits out the post-query re-render rather than reading a + // point-in-time count. + await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToHaveCountAsync(0); + + // Now filter by Channel = DbOutbound with the db target — the row flips. + await SetSearchKeywordAsync(page, targetPrefix + "db"); + await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound"); + await page.Locator("[data-test='site-calls-query']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToHaveCountAsync(0); + } + finally + { + await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + + [SkippableFact] + public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog() + { + Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/sc-drill-in/{runId}/"; + var trackedId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + await SiteCallDataSeeder.InsertSiteCallAsync( + trackedOperationId: trackedId, channel: "ApiOutbound", target: targetPrefix + "endpoint", + sourceSite: "plant-a", status: "Delivered", retryCount: 0, + createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now); + + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await SetSearchKeywordAsync(page, targetPrefix + "endpoint"); + await page.Locator("[data-test='site-calls-query']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The row carries a "View audit history" link whose href is the + // canonical correlationId deep-link — the TrackedOperationId IS the + // audit CorrelationId. + var link = page.Locator($"a[data-test='audit-link-{trackedId}']"); + await Assertions.Expect(link).ToBeVisibleAsync(); + var href = await link.GetAttributeAsync("href"); + Assert.Equal($"/audit/log?correlationId={trackedId}", href); + + // Following the link lands on the Audit Log page with the query-string + // drill-in context intact. + await link.ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + Assert.Contains($"correlationId={trackedId}", page.Url); + await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync(); + } + finally + { + await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + + [SkippableFact] + public async Task RetryDiscard_VisibleOnlyOnParkedRows() + { + Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/sc-actions/{runId}/"; + var parkedId = Guid.NewGuid(); + var failedId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + // One Parked row (actionable) and one Failed row (terminal — not + // actionable from central). + await SiteCallDataSeeder.InsertSiteCallAsync( + trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked", + sourceSite: "plant-a", status: "Parked", retryCount: 3, + lastError: "HTTP 503 from ERP", httpStatus: 503, + createdAtUtc: now, updatedAtUtc: now); + await SiteCallDataSeeder.InsertSiteCallAsync( + trackedOperationId: failedId, channel: "DbOutbound", target: targetPrefix + "failed", + sourceSite: "plant-a", status: "Failed", retryCount: 1, + lastError: "constraint violation", + createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now); + + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Query the parked row first. + await SetSearchKeywordAsync(page, targetPrefix + "parked"); + await page.Locator("[data-test='site-calls-query']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" }); + await Assertions.Expect(parkedRow).ToBeVisibleAsync(); + // The Parked row exposes both Retry and Discard. + await Assertions.Expect(parkedRow.Locator("button:has-text('Retry')")).ToBeVisibleAsync(); + await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync(); + + // Now the Failed row — Retry/Discard are absent. + await SetSearchKeywordAsync(page, targetPrefix + "failed"); + await page.Locator("[data-test='site-calls-query']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var failedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "failed" }); + await Assertions.Expect(failedRow).ToBeVisibleAsync(); + Assert.Equal(0, await failedRow.Locator("button:has-text('Retry')").CountAsync()); + Assert.Equal(0, await failedRow.Locator("button:has-text('Discard')").CountAsync()); + } + finally + { + await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + + [SkippableFact] + public async Task RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast() + { + Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/sc-retry-click/{runId}/"; + var parkedId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + // A single Parked row — the only status from which Retry/Discard can + // be relayed to the owning site. Unlike the display-only tests above, + // this one actually relays to the owning site, so the SourceSite must + // be a *real* site identifier from the running cluster (site-a) and + // not the cosmetic "plant-a" label: an unknown site has no registered + // ClusterClient, so CentralCommunicationActor drops the envelope + // without replying and the relay only resolves on the 10s inner Ask + // timeout — too slow for the toast assertion below. Relayed to a live + // site, the site finds no parked S&F message for this freshly-seeded + // GUID and replies a fast NotParked ack, which still surfaces a toast. + await SiteCallDataSeeder.InsertSiteCallAsync( + trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked", + sourceSite: "site-a", status: "Parked", retryCount: 3, + lastError: "HTTP 503 from ERP", httpStatus: 503, + createdAtUtc: now, updatedAtUtc: now); + + var page = await _fixture.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await SetSearchKeywordAsync(page, targetPrefix + "parked"); + await page.Locator("[data-test='site-calls-query']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" }); + await Assertions.Expect(parkedRow).ToBeVisibleAsync(); + + // Click Retry — this opens the confirmation dialog (DialogHost modal). + await parkedRow.Locator("button:has-text('Retry')").ClickAsync(); + + // Confirm the relay in the dialog footer ("Confirm" — the non-danger + // label; Discard would render "Delete"). + var confirmButton = page.Locator(".modal-footer button:has-text('Confirm')"); + await Assertions.Expect(confirmButton).ToBeVisibleAsync(); + await confirmButton.ClickAsync(); + + // The relay outcome surfaces on a toast — Applied, NotParked or, if + // the owning site is offline in this environment, SiteUnreachable. + // We only assert that an outcome toast appears (exactly one — the + // single-toast contract), not which one, since the live cluster + // state determines the outcome. The wait is generous (15s): the + // relay round-trips to the site over ClusterClient, and a worst-case + // path can sit on the 10s inner relay timeout before the response — + // and the toast itself auto-dismisses 5s after it appears, so the + // assertion must catch it inside that window. + var toast = page.Locator(".toast"); + await Assertions.Expect(toast).ToBeVisibleAsync( + new() { Timeout = 15_000 }); + Assert.Equal(1, await toast.CountAsync()); + } + finally + { + await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs index 286198b..b91cc07 100644 --- a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs @@ -160,10 +160,10 @@ public class AuditExportEndpointsTests await repo.Received().QueryAsync( Arg.Is(f => - f.Channel == AuditChannel.ApiOutbound && - f.Kind == AuditKind.ApiCall && - f.Status == AuditStatus.Failed && - f.SourceSiteId == "plant-a" && + f.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound && + f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall && + f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed && + f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" && f.Target == "PaymentApi" && f.Actor == "apikey-1" && f.CorrelationId == Guid.Parse(correlationId) && @@ -188,10 +188,10 @@ public class AuditExportEndpointsTests await repo.Received().QueryAsync( Arg.Is(f => - f.Channel == null && - f.Kind == null && - f.Status == null && - f.SourceSiteId == null && + f.Channels == null && + f.Kinds == null && + f.Statuses == null && + f.SourceSiteIds == null && f.Target == null && f.Actor == null && f.CorrelationId == null && @@ -216,7 +216,7 @@ public class AuditExportEndpointsTests _ = await response.Content.ReadAsStringAsync(); await repo.Received().QueryAsync( - Arg.Is(f => f.Channel == null), + Arg.Is(f => f.Channels == null), Arg.Any(), Arg.Any()); } diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs index 282c49c..484c2bb 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs @@ -77,10 +77,30 @@ public class AuditFilterBarTests : BunitContext cut.Find("[data-test=\"filter-apply\"]").Click(); Assert.NotNull(captured); - Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel); + Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels); Assert.Equal("Plant-A-OPC", captured.Target); } + [Fact] + public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels() + { + // Task 9: ToFilter no longer collapses the chip multi-select — every + // selected channel chip reaches the filter's Channels list. + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click(); + cut.Find("[data-test=\"chip-channel-Notification\"]").Click(); + cut.Find("[data-test=\"filter-apply\"]").Click(); + + Assert.NotNull(captured); + Assert.NotNull(captured!.Channels); + Assert.Equal(2, captured.Channels!.Count); + Assert.Contains(AuditChannel.ApiOutbound, captured.Channels); + Assert.Contains(AuditChannel.Notification, captured.Channels); + } + [Fact] public void Channel_Narrows_Kind_Options_When_Selected() { @@ -117,14 +137,38 @@ public class AuditFilterBarTests : BunitContext cut.Find("[data-test=\"filter-apply\"]").Click(); Assert.NotNull(captured); - // Single-value filter contract: Failed leads the non-success set. - Assert.Equal(AuditStatus.Failed, captured!.Status); + // Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}. + Assert.NotNull(captured!.Statuses); + Assert.Equal(3, captured.Statuses!.Count); + Assert.Contains(AuditStatus.Failed, captured.Statuses); + Assert.Contains(AuditStatus.Parked, captured.Statuses); + Assert.Contains(AuditStatus.Discarded, captured.Statuses); // Now pin an explicit Status chip — Errors-only must yield (chip wins). cut.Find("[data-test=\"chip-status-Delivered\"]").Click(); cut.Find("[data-test=\"filter-apply\"]").Click(); - Assert.Equal(AuditStatus.Delivered, captured!.Status); + Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses); + } + + [Fact] + public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses() + { + // Task 9: multiple explicit Status chips all reach the filter — and they + // win over the Errors-only default. + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + cut.Find("[data-test=\"chip-status-Delivered\"]").Click(); + cut.Find("[data-test=\"chip-status-Failed\"]").Click(); + cut.Find("[data-test=\"filter-apply\"]").Click(); + + Assert.NotNull(captured); + Assert.NotNull(captured!.Statuses); + Assert.Equal(2, captured.Statuses!.Count); + Assert.Contains(AuditStatus.Delivered, captured.Statuses); + Assert.Contains(AuditStatus.Failed, captured.Statuses); } [Fact] diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs index fa8fdec..ab30a70 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs @@ -43,6 +43,12 @@ public class AuditResultsGridTests : BunitContext _service = Substitute.For(); _service.DefaultPageSize.Returns(100); Services.AddSingleton(_service); + + // The grid's OnAfterRenderAsync calls into audit-grid.js (init + the + // sessionStorage load). Loose mode lets those unconfigured calls no-op + // — auditGrid.load returns null (no prior state) unless a test sets up + // an explicit JSInterop.Setup to return a stored payload. + JSInterop.Mode = JSRuntimeMode.Loose; } private void StubPage(IReadOnlyList rows) @@ -131,4 +137,133 @@ public class AuditResultsGridTests : BunitContext var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]"); Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty); } + + // --- column resize + reorder UX (#23 follow-ups Task 10) --------------- + // + // The drag interaction itself is browser-side (audit-grid.js) and covered + // by the Playwright suite. The bUnit tests below exercise the .NET-side + // load/apply/persist logic that the JS callbacks drive: graceful handling + // of stored orders, the reorder slot-move maths, and the resize minimum. + + /// Column keys in default (spec) order — the fallback used everywhere. + private static readonly string[] DefaultOrder = + { + "OccurredAtUtc", "Site", "Channel", "Kind", "Status", + "Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage", + }; + + private static int HeaderIndex(string markup, string key) + => markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal); + + [Fact] + public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn() + { + StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + foreach (var key in DefaultOrder) + { + // Each carries the stable drag key and a resize handle. + Assert.Contains($"data-col-key=\"{key}\"", cut.Markup); + Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup); + } + } + + [Fact] + public void ColumnOrderParameter_DrivesHeaderOrder() + { + StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); + + var cut = Render(p => p + .Add(c => c.Filter, new AuditLogQueryFilter()) + .Add(c => c.ColumnOrder, new[] { "Status", "Site" })); + + // Status + Site move to the front; the omitted columns still render, + // appended in default order — Status precedes Site precedes Channel. + Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site")); + Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel")); + // No column is dropped — all ten headers are present. + foreach (var key in DefaultOrder) + { + Assert.Contains($"data-col-key=\"{key}\"", cut.Markup); + } + } + + [Fact] + public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists() + { + StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // Drag Status onto OccurredAtUtc — Status should land in slot 0. + await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc")); + + Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc")); + // The new order was persisted to sessionStorage under the order key. + // Loose-mode JSInterop records every InvokeVoidAsync; find the save call. + var save = JSInterop.Invocations + .Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder"); + Assert.Contains("Status", (string)save.Arguments[1]!); + } + + [Fact] + public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists() + { + StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // A drag that would shrink the column to 10px must clamp to the 64px floor. + await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10)); + + // The clamped width is reflected as the --audit-col-width custom property. + Assert.Contains("--audit-col-width: 64px", cut.Markup); + // The width was persisted to sessionStorage under the widths key. + Assert.Contains(JSInterop.Invocations, + i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths"); + } + + [Fact] + public void StoredOrder_WithUnknownKey_DegradesGracefully() + { + StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); + // A stale persisted order naming a removed column ("LegacyCol") plus a + // subset of real columns — the unknown key must be dropped and the + // omitted real columns appended in default order, never throwing. + JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder") + .SetResult("[\"Status\",\"LegacyCol\",\"Site\"]"); + JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths") + .SetResult((string?)null); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // Restored order applied: Status then Site at the front. + Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site")); + // The unknown key produced no header and did not break rendering. + Assert.DoesNotContain("LegacyCol", cut.Markup); + // All ten real columns still present. + foreach (var key in DefaultOrder) + { + Assert.Contains($"data-col-key=\"{key}\"", cut.Markup); + } + } + + [Fact] + public void StoredWidths_ForUnknownColumn_AreIgnored() + { + StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) }); + JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder") + .SetResult((string?)null); + // A width for a real column and one for a removed column. + JSInterop.Setup("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths") + .SetResult("{\"Target\":220,\"LegacyCol\":300}"); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // The valid column's width was applied; the stale one silently ignored. + Assert.Contains("--audit-col-width: 220px", cut.Markup); + Assert.DoesNotContain("300px", cut.Markup); + } } diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs new file mode 100644 index 0000000..8dfdd04 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Health/SiteCallKpiTilesTests.cs @@ -0,0 +1,177 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.CentralUI.Components.Health; +using ScadaLink.Commons.Messages.Audit; + +namespace ScadaLink.CentralUI.Tests.Components.Health; + +/// +/// bUnit tests for (Site Call Audit #22, Task 7). +/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked — +/// from a single snapshot. The tests pin: +/// +/// +/// Three-tile render contract (data-test attributes for stable selectors). +/// Tile values render the snapshot's counters. +/// Threshold borders fire correctly — danger on Parked > 0, warning +/// on Stuck > 0, none when those counts are zero, none on Buffered. +/// Unavailable snapshot renders em dashes plus the error message. +/// Tile clicks navigate to the correct pre-filtered Site Calls report URL. +/// +/// +public class SiteCallKpiTilesTests : BunitContext +{ + private static SiteCallKpiResponse MakeSnapshot(int buffered, int parked, int stuck) => + new( + CorrelationId: "k", + Success: true, + ErrorMessage: null, + BufferedCount: buffered, + ParkedCount: parked, + FailedLastInterval: 0, + DeliveredLastInterval: 0, + OldestPendingAge: null, + StuckCount: stuck); + + [Fact] + public void Renders_ThreeTiles_FromSnapshot() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 120, parked: 3, stuck: 7)) + .Add(c => c.IsAvailable, true)); + + // Three stable data-test selectors — the contract for both these tests + // and any future Playwright sweep. + Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup); + Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup); + Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup); + + // Tile values render the snapshot's counters. + Assert.Contains(">120<", cut.Markup); // buffered + Assert.Contains(">7<", cut.Markup); // stuck + Assert.Contains(">3<", cut.Markup); // parked + } + + [Fact] + public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage() + { + var cut = Render(p => p + .Add(c => c.Snapshot, (SiteCallKpiResponse?)null) + .Add(c => c.IsAvailable, false) + .Add(c => c.ErrorMessage, "site call repository unavailable")); + + // All three tiles show em dashes — em dash (U+2014) "—" must appear. + Assert.Contains("—", cut.Markup); + // Inline error message renders below. + Assert.Contains("Site Call KPIs unavailable", cut.Markup); + Assert.Contains("site call repository unavailable", cut.Markup); + } + + [Fact] + public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]"); + Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void ParkedTile_NoDangerBorder_WhenParkedZero() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]"); + Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void StuckTile_GetsWarningBorder_WhenStuckAboveZero() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]"); + Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty); + // Warning, not danger — Stuck is the softer signal. + Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void StuckTile_NoWarningBorder_WhenStuckZero() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]"); + Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount() + { + // A non-zero buffer is normal operation — the Buffered tile is a plain + // count tile and never gets a danger/warning border. + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 5000, parked: 0, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]"); + var cls = tile.GetAttribute("class") ?? string.Empty; + Assert.DoesNotContain("border-danger", cls); + Assert.DoesNotContain("border-warning", cls); + } + + [Fact] + public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 50, parked: 0, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]"); + tile.Click(); + + // Unfiltered /site-calls/report — no query string. + Assert.EndsWith("/site-calls/report", nav.Uri); + } + + [Fact] + public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6)) + .Add(c => c.IsAvailable, true)); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]"); + tile.Click(); + + // Spec: Stuck tile drills into the report's "stuck only" filter. + Assert.Contains("/site-calls/report?stuck=true", nav.Uri); + } + + [Fact] + public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter() + { + var cut = Render(p => p + .Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0)) + .Add(c => c.IsAvailable, true)); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]"); + tile.Click(); + + // Spec: Parked tile drills into ?status=Parked. + Assert.Contains("/site-calls/report?status=Parked", nav.Uri); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs index 136a929..60e3d56 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs @@ -36,10 +36,10 @@ public class AuditLogPageExportUrlTests { var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); var filter = new AuditLogQueryFilter( - Channel: AuditChannel.ApiOutbound, - Kind: AuditKind.ApiCall, - Status: AuditStatus.Failed, - SourceSiteId: "plant-a", + Channels: new[] { AuditChannel.ApiOutbound }, + Kinds: new[] { AuditKind.ApiCall }, + Statuses: new[] { AuditStatus.Failed }, + SourceSiteIds: new[] { "plant-a" }, Target: "PaymentApi", Actor: "apikey-1", CorrelationId: corr, @@ -65,7 +65,7 @@ public class AuditLogPageExportUrlTests [Fact] public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams() { - var filter = new AuditLogQueryFilter(Channel: AuditChannel.Notification); + var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.Notification }); var url = AuditLogPage.BuildExportUrl(filter); @@ -74,4 +74,22 @@ public class AuditLogPageExportUrlTests Assert.Single(query); Assert.Equal("Notification", query["channel"]); } + + [Fact] + public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams() + { + // Task 9: each multi-value dimension emits one repeated query-string key + // per selected value so the export endpoint's ParseFilter sees them all. + var filter = new AuditLogQueryFilter( + Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, + Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked }, + SourceSiteIds: new[] { "plant-a", "plant-b" }); + + var url = AuditLogPage.BuildExportUrl(filter); + + var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query); + Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, query["channel"].ToArray()); + Assert.Equal(new[] { "Failed", "Parked" }, query["status"].ToArray()); + Assert.Equal(new[] { "plant-a", "plant-b" }, query["site"].ToArray()); + } } diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs index e3a754d..e510815 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs @@ -46,6 +46,15 @@ namespace ScadaLink.CentralUI.Tests.Pages; /// public class AuditLogPagePermissionTests : BunitContext { + public AuditLogPagePermissionTests() + { + // The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the + // column resize/reorder UX via audit-grid.js (a sessionStorage load + + // an init call). Loose mode lets those unconfigured JS calls no-op so + // the permission-gating tests need not configure browser interop. + JSInterop.Mode = JSRuntimeMode.Loose; + } + private static ClaimsPrincipal BuildPrincipal(params string[] roles) { var claims = new List { new("Username", "tester") }; diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 955c314..328048e 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -28,6 +28,15 @@ namespace ScadaLink.CentralUI.Tests.Pages; /// public class AuditLogPageScaffoldTests : BunitContext { + public AuditLogPageScaffoldTests() + { + // The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the + // column resize/reorder UX via audit-grid.js (a sessionStorage load + + // an init call). Loose mode lets those unconfigured JS calls no-op so + // the page scaffold smoke tests need not configure browser interop. + JSInterop.Mode = JSRuntimeMode.Loose; + } + private static ClaimsPrincipal BuildPrincipal(params string[] roles) { var claims = new List { new("Username", "tester") }; @@ -197,7 +206,8 @@ public class AuditLogPageScaffoldTests : BunitContext cut.WaitForAssertion(() => { _queryService.Received().QueryAsync( - Arg.Is(f => f.SourceSiteId == "plant-a"), + Arg.Is(f => + f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"), Arg.Any(), Arg.Any()); }); @@ -218,7 +228,8 @@ public class AuditLogPageScaffoldTests : BunitContext cut.WaitForAssertion(() => { _queryService.Received().QueryAsync( - Arg.Is(f => f.Status == AuditStatus.Failed), + Arg.Is(f => + f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed), Arg.Any(), Arg.Any()); }); diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs index 78dbd52..b575f8c 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs @@ -9,6 +9,7 @@ using NSubstitute; using ScadaLink.CentralUI.Services; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Audit; using ScadaLink.Commons.Messages.Notification; using ScadaLink.Commons.Types; using ScadaLink.Communication; @@ -37,6 +38,13 @@ public class HealthPageTests : BunitContext new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3, DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6)); + // Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests + // that target the Site Call tiles override this before rendering. + private SiteCallKpiResponse _siteCallKpiReply = + new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1, + DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3), + StuckCount: 5); + public HealthPageTests() { _comms = new CommunicationService( @@ -45,6 +53,9 @@ public class HealthPageTests : BunitContext var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this))); _comms.SetNotificationOutbox(outbox); + + var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))); + _comms.SetSiteCallAudit(siteCallAudit); Services.AddSingleton(_comms); var aggregator = Substitute.For(); @@ -133,6 +144,53 @@ public class HealthPageTests : BunitContext }); } + [Fact] + public void Renders_SiteCallKpiTiles_WithValues() + { + var cut = Render(); + + // KPI data arrives via an async actor Ask after first render. + cut.WaitForAssertion(() => + { + Assert.Contains("Site Calls", cut.Markup); + // The three Site Call tiles render at the documented data-test selectors. + Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup); + Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup); + Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup); + // KPI numeric values surface in the tiles. + Assert.Contains(">9<", cut.Markup); // BufferedCount + Assert.Contains(">5<", cut.Markup); // StuckCount + Assert.Contains(">2<", cut.Markup); // ParkedCount + }); + } + + [Fact] + public void RendersLinkToTheSiteCallsReportPage() + { + var cut = Render(); + var link = cut.Find("a[href='/site-calls/report']"); + Assert.Contains("View details", link.TextContent); + } + + [Fact] + public void SiteCallKpiFailure_ShowsGracefulFallback() + { + _siteCallKpiReply = new SiteCallKpiResponse( + "k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + // Failure must not crash the page; tiles fall back to a dash and the + // inline error message surfaces. + Assert.Contains("Site Calls", cut.Markup); + Assert.Contains("Site Call KPIs unavailable", cut.Markup); + Assert.Contains("site call repository unavailable", cut.Markup); + Assert.Contains(">—<", cut.Markup); + }); + } + [Fact] public void OutboxKpiFailure_ShowsGracefulFallback() { @@ -170,4 +228,16 @@ public class HealthPageTests : BunitContext Receive(_ => Sender.Tell(test._kpiReply)); } } + + /// + /// Stand-in for the Site Call Audit actor. Replies to the KPI request with + /// the test's currently-scripted response. + /// + private sealed class ScriptedSiteCallAuditActor : ReceiveActor + { + public ScriptedSiteCallAuditActor(HealthPageTests test) + { + Receive(_ => Sender.Tell(test._siteCallKpiReply)); + } + } } diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs new file mode 100644 index 0000000..6dd2852 --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -0,0 +1,532 @@ +using System.Security.Claims; +using Akka.Actor; +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ScadaLink.CentralUI.Components.Shared; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Messages.Audit; +using ScadaLink.Communication; +using ScadaLink.Security; +using SiteCallsReportPage = ScadaLink.CentralUI.Components.Pages.SiteCalls.SiteCallsReport; + +namespace ScadaLink.CentralUI.Tests.Pages; + +/// +/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22). +/// +/// Testability note: is a concrete class with +/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all +/// route through an injected (the Site Call Audit proxy), +/// so the tests wire a real, lightweight with a scripted +/// that replies with fixed responses — the same seam +/// SetSiteCallAudit exists for. Mirrors . +/// +public class SiteCallsReportPageTests : BunitContext +{ + private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests"); + private readonly CommunicationService _comms; + + private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + // Mutable scripted reply — individual tests can override before rendering. + private SiteCallQueryResponse _queryReply = new( + "q", true, null, + new List + { + new(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked", + RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503, + CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5), + TerminalAtUtc: null, IsStuck: true), + new(FailedId, "plant-b", "DbOutbound", "Historian.Write", "Failed", + RetryCount: 1, LastError: "constraint violation", HttpStatus: null, + CreatedAtUtc: DateTime.UtcNow.AddHours(-2), UpdatedAtUtc: DateTime.UtcNow.AddHours(-2), + TerminalAtUtc: DateTime.UtcNow.AddHours(-2), IsStuck: false), + }, + NextAfterCreatedAtUtc: null, + NextAfterId: null); + + // Records the most recent retry/discard requests the actor received. + private readonly List _queryRequests = new(); + private readonly List _retryRequests = new(); + private readonly List _discardRequests = new(); + + // Scripted relay responses — overridable per test. + private RetrySiteCallResponse _retryReply = + new("q", SiteCallRelayOutcome.Applied, true, true, null); + private DiscardSiteCallResponse _discardReply = + new("q", SiteCallRelayOutcome.Applied, true, true, null); + + public SiteCallsReportPageTests() + { + _comms = new CommunicationService( + Options.Create(new CommunicationOptions()), + NullLogger.Instance); + + var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this))); + _comms.SetSiteCallAudit(auditProxy); + + Services.AddSingleton(_comms); + Services.AddSingleton(new AlwaysConfirmDialogService()); + + var siteRepo = Substitute.For(); + siteRepo.GetAllSitesAsync(Arg.Any()) + .Returns(Task.FromResult>(new List + { + new("Plant A", "plant-a") { Id = 1 }, + new("Plant B", "plant-b") { Id = 2 }, + })); + Services.AddSingleton(siteRepo); + + var claims = new[] + { + new Claim("Username", "tester"), + new Claim(ClaimTypes.Role, "Deployment"), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + } + + [Fact] + public void Page_RequiresDeploymentPolicy() + { + var attr = typeof(SiteCallsReportPage) + .GetCustomAttributes(typeof(AuthorizeAttribute), true) + .Cast() + .FirstOrDefault(); + + Assert.NotNull(attr); + Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy); + } + + [Fact] + public void Renders_SiteCallRows() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Contains("ERP.GetOrder", cut.Markup); + Assert.Contains("Historian.Write", cut.Markup); + }); + } + + [Fact] + public void StuckRow_IsBadged() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + var stuckRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("ERP.GetOrder")); + Assert.Contains("badge", stuckRow.InnerHtml); + Assert.Contains("Stuck", stuckRow.TextContent); + }); + } + + [Fact] + public void RetryDiscardButtons_ShownOnlyOnParkedRows() + { + var cut = Render(); + + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + var parkedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("ERP.GetOrder")); + var failedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("Historian.Write")); + + // The Parked row carries Retry + Discard buttons. + Assert.Contains(parkedRow.QuerySelectorAll("button"), + b => b.TextContent.Contains("Retry")); + Assert.Contains(parkedRow.QuerySelectorAll("button"), + b => b.TextContent.Contains("Discard")); + + // The Failed row carries neither — Retry/Discard are Parked-only. + Assert.DoesNotContain(failedRow.QuerySelectorAll("button"), + b => b.TextContent.Contains("Retry")); + Assert.DoesNotContain(failedRow.QuerySelectorAll("button"), + b => b.TextContent.Contains("Discard")); + } + + [Fact] + public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite() + { + var cut = Render(); + + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + var parkedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("ERP.GetOrder")); + var retryButton = parkedRow.QuerySelectorAll("button") + .First(b => b.TextContent.Contains("Retry")); + + retryButton.Click(); + + cut.WaitForAssertion(() => + { + Assert.Single(_retryRequests); + Assert.Equal(ParkedId, _retryRequests[0].TrackedOperationId); + // The relay carries the owning site so central can route it. + Assert.Equal("plant-a", _retryRequests[0].SourceSite); + }); + } + + [Fact] + public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite() + { + var cut = Render(); + + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + var parkedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("ERP.GetOrder")); + var discardButton = parkedRow.QuerySelectorAll("button") + .First(b => b.TextContent.Contains("Discard")); + + discardButton.Click(); + + cut.WaitForAssertion(() => + { + Assert.Single(_discardRequests); + Assert.Equal(ParkedId, _discardRequests[0].TrackedOperationId); + Assert.Equal("plant-a", _discardRequests[0].SourceSite); + }); + } + + [Fact] + public void RetryRelay_SiteUnreachable_ShowsDistinctMessage() + { + // The relay never reached the owning site — a transient transport + // condition, surfaced distinctly from a generic failure. + _retryReply = new RetrySiteCallResponse( + "q", SiteCallRelayOutcome.SiteUnreachable, Success: false, SiteReachable: false, + ErrorMessage: "Site plant-a is offline — relay not delivered."); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + var parkedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("ERP.GetOrder")); + parkedRow.QuerySelectorAll("button") + .First(b => b.TextContent.Contains("Retry")) + .Click(); + + cut.WaitForAssertion(() => + Assert.Contains("offline", cut.Markup)); + } + + [Fact] + public void QueryFailure_ShowsErrorMessage() + { + _queryReply = new SiteCallQueryResponse( + "q", false, "site call query backend unavailable", + new List(), null, null); + + var cut = Render(); + + cut.WaitForAssertion(() => + Assert.Contains("site call query backend unavailable", cut.Markup)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Drill-in — every row carries a "View audit history" link to + // /audit/log?correlationId={TrackedOperationId}. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + // Both rows (Parked + Failed) surface the link — the drill-in is + // row-scope, not status-scope. + var parkedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("ERP.GetOrder")); + var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]"); + Assert.NotNull(link); + Assert.Equal( + $"/audit/log?correlationId={ParkedId}", + link!.GetAttribute("href")); + Assert.Contains("View audit history", link.TextContent); + + var failedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("Historian.Write")); + var failedLink = failedRow.QuerySelector("a[data-test^=\"audit-link-\"]"); + Assert.NotNull(failedLink); + Assert.Equal( + $"/audit/log?correlationId={FailedId}", + failedLink!.GetAttribute("href")); + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Keyset paging — Next is driven by the response's NextAfter* cursor, not by + // page numbers; the request echoes the cursor back to the actor. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void Paging_NextButton_HiddenWhenNoFurtherPage() + { + // The default reply returns 2 rows and no NextAfter* cursor — there is no + // further page, so Next is disabled. + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + var next = cut.Find("[data-test='site-calls-next']"); + Assert.True(next.HasAttribute("disabled")); + var prev = cut.Find("[data-test='site-calls-prev']"); + Assert.True(prev.HasAttribute("disabled")); + } + + [Fact] + public void Paging_NextButton_AdvancesUsingKeysetCursor() + { + // A full page (PageSize=50 rows) plus a NextAfter* cursor: Next is live + // and, when clicked, the follow-up query carries that cursor. + var firstPage = new List(); + for (var i = 0; i < 50; i++) + { + firstPage.Add(new SiteCallSummary( + Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered", + RetryCount: 0, LastError: null, HttpStatus: 200, + CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i), + TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false)); + } + + var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); + var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999"); + _queryReply = new SiteCallQueryResponse( + "q", true, null, firstPage, + NextAfterCreatedAtUtc: cursorCreated, + NextAfterId: cursorId); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ERP.Op0")); + + var next = cut.Find("[data-test='site-calls-next']"); + Assert.False(next.HasAttribute("disabled")); + + next.Click(); + + cut.WaitForAssertion(() => + { + // Two queries fired: the initial load and the Next click. The second + // carries the keyset cursor echoed by the first response. + Assert.Equal(2, _queryRequests.Count); + Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc); + Assert.Equal(cursorId, _queryRequests[1].AfterId); + }); + } + + [Fact] + public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor() + { + // The keyset back-stack is the trickiest paging path: Next pushes the + // current cursor, Prev pops it and refetches that prior page. Page 1 is + // opened with the empty (null, null) cursor, so after Next→Previous the + // follow-up query must carry (null, null) again. + var firstPage = new List(); + for (var i = 0; i < 50; i++) + { + firstPage.Add(new SiteCallSummary( + Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered", + RetryCount: 0, LastError: null, HttpStatus: 200, + CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i), + TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false)); + } + + var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); + var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999"); + _queryReply = new SiteCallQueryResponse( + "q", true, null, firstPage, + NextAfterCreatedAtUtc: cursorCreated, + NextAfterId: cursorId); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ERP.Op0")); + + // Step forward — query 2 carries the keyset cursor. + var next = cut.Find("[data-test='site-calls-next']"); + next.Click(); + cut.WaitForAssertion(() => + { + Assert.Equal(2, _queryRequests.Count); + Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc); + }); + + // Previous is now live (the back-stack has one entry); click it. + var prev = cut.Find("[data-test='site-calls-prev']"); + Assert.False(prev.HasAttribute("disabled")); + prev.Click(); + + cut.WaitForAssertion(() => + { + // Query 3 is the Previous refetch — the back-stack popped the page-1 + // cursor, which is the empty (null, null) first-page cursor. + Assert.Equal(3, _queryRequests.Count); + Assert.Null(_queryRequests[2].AfterCreatedAtUtc); + Assert.Null(_queryRequests[2].AfterId); + // Back on page 1, the back-stack is empty again so Previous re-disables. + Assert.True(cut.Find("[data-test='site-calls-prev']").HasAttribute("disabled")); + }); + } + + [Fact] + public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast() + { + // NotParked is a definitive answer from the site (nothing to do), not a + // failure — it surfaces as a single info toast, never an error. This + // also guards the single-toast contract: a non-Applied outcome must + // produce exactly one toast. + _retryReply = new RetrySiteCallResponse( + "q", SiteCallRelayOutcome.NotParked, Success: false, SiteReachable: true, + ErrorMessage: "The cached call is no longer parked."); + + var cut = Render(); + cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder")); + + var parkedRow = cut.FindAll("tbody tr") + .First(r => r.TextContent.Contains("ERP.GetOrder")); + parkedRow.QuerySelectorAll("button") + .First(b => b.TextContent.Contains("Retry")) + .Click(); + + cut.WaitForAssertion(() => + { + Assert.Contains("no longer parked", cut.Markup); + // Exactly one toast — the ShowRelayOutcome switch owns the single + // toast; no second (error) toast piggybacks on the same response. + Assert.Single(cut.FindAll(".toast")); + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link + // here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The + // params must seed the filter BEFORE the first query so the initial grid load + // is already filtered, and the filter card controls must reflect the values. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked() + { + // The Parked KPI tile emits ?status=Parked — set the URI before render. + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo("/site-calls/report?status=Parked"); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + // The first (and only) query the page issues carries the Parked + // status filter — the grid load is pre-filtered, not unfiltered. + Assert.Single(_queryRequests); + Assert.Equal("Parked", _queryRequests[0].StatusFilter); + + // The Status