Merge branch 'feature/audit-log-followups': Audit Log #23 deferred follow-ups
Implements the five deferred follow-ups from the Audit Log #23 roadmap: - Real ClusterClient-based site->central audit push (replaces NoOpSiteStreamAuditClient) - Consolidated the duplicated AuditEvent/SiteCall DTO mappers - Site Calls UI page + read-side backend + central->site Retry/Discard relay + Health KPI tiles - Multi-value AuditLogQueryFilter end-to-end (repository, ManagementService, CLI, Central UI) - Audit results grid column resize/reorder UX Full solution build clean; full test suite green including Playwright 60/60.
This commit is contained in:
@@ -11,12 +11,17 @@
|
|||||||
>
|
>
|
||||||
> **Deferred to v1.x (out of scope, intentionally not implemented):** hash-chain tamper
|
> **Deferred to v1.x (out of scope, intentionally not implemented):** hash-chain tamper
|
||||||
> evidence (`audit verify-chain` ships as a no-op stub), Parquet export (`format=parquet`
|
> evidence (`audit verify-chain` ships as a no-op stub), Parquet export (`format=parquet`
|
||||||
> returns HTTP 501), per-channel retention overrides. **Deferred follow-ups noted during
|
> returns HTTP 501), per-channel retention overrides. **Follow-ups noted during
|
||||||
> implementation:** the real site→central gRPC push client (M6 wired the pull RPC + a mockable
|
> implementation — now complete:** the five follow-ups deferred above (the real
|
||||||
> push seam; `NoOpSiteStreamAuditClient` remains the production binding); consolidation of the
|
> site→central push client; consolidation of the 4 DTO mapper copies; the Site Calls UI
|
||||||
> 4 DTO mapper copies; Site Calls UI page + its Audit drill-in; multi-value filter dimensions
|
> page + its Audit drill-in; multi-value filter dimensions; audit-results-grid drag
|
||||||
> (`AuditLogQueryFilter` is single-value per dimension, so UI chips / CLI flags collapse to the
|
> resize/reorder UX) were all implemented on the `feature/audit-log-followups` branch
|
||||||
> first value); audit-results-grid drag resize/reorder UX.
|
> per `docs/plans/2026-05-21-audit-log-followups.md`. The site→central transport shipped
|
||||||
|
> as a **ClusterClient-based push** (`ClusterClientSiteAuditClient`, reusing the same
|
||||||
|
> ClusterClient command/control transport notifications use) rather than the gRPC push
|
||||||
|
> originally sketched here — `ClusterClientSiteAuditClient` is now the production binding
|
||||||
|
> for site roles, with `NoOpSiteStreamAuditClient` retained only for central/test
|
||||||
|
> composition roots; and `AuditLogQueryFilter` is now multi-value per dimension.
|
||||||
>
|
>
|
||||||
> **For Claude:** REQUIRED SUB-SKILL FLOW per milestone: `brainstorming` → `writing-plans` → `subagent-driven-development`. Use `docs/requirements/Component-AuditLog.md` + `alog.md` as the spec; this document is the roadmap that sequences milestones and locks acceptance criteria for each. **M1 carries full TDD-level task detail; M2–M8 are milestone-shape detail and will be expanded into bite-sized plans by their own writing-plans pass when their turn comes.**
|
> **For Claude:** REQUIRED SUB-SKILL FLOW per milestone: `brainstorming` → `writing-plans` → `subagent-driven-development`. Use `docs/requirements/Component-AuditLog.md` + `alog.md` as the spec; this document is the roadmap that sequences milestones and locks acceptance criteria for each. **M1 carries full TDD-level task detail; M2–M8 are milestone-shape detail and will be expanded into bite-sized plans by their own writing-plans pass when their turn comes.**
|
||||||
|
|
||||||
|
|||||||
249
docs/plans/2026-05-21-audit-log-followups.md
Normal file
249
docs/plans/2026-05-21-audit-log-followups.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Audit Log #23 — Deferred Follow-ups Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (bundled cadence — one implementer + one review pass per task).
|
||||||
|
|
||||||
|
**Goal:** Close the five deferred implementation follow-ups from the Audit Log #23 roadmap so site audit events actually reach central, the audit/SiteCall surfaces are complete, and known tech debt is paid down.
|
||||||
|
|
||||||
|
**Architecture:** Five independent-ish workstreams against the existing ScadaLink codebase. The headline change: site→central audit forwarding moves from the production `NoOpSiteStreamAuditClient` stub to a real **ClusterClient-based push** — the same transport notifications already use (`SiteCommunicationActor` → `ClusterClient.Send("/user/central-communication", …)` → `CentralCommunicationActor`), avoiding a new central-hosted gRPC server. The remaining four follow-ups are scoped tech-debt / UI / contract changes.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, Akka.NET (ClusterClient, ClusterClientReceptionist, cluster singletons, TestKit), EF Core 10 (MS SQL + SQLite providers), Blazor Server + Bootstrap CSS (no third-party UI libs), System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute, Playwright.
|
||||||
|
|
||||||
|
**Spec sources:** `alog.md`, `docs/requirements/Component-AuditLog.md`, `docs/requirements/Component-SiteCallAudit.md`, `docs/plans/2026-05-20-audit-log-code-roadmap.md` (header lines 14–19 enumerate the deferred items).
|
||||||
|
|
||||||
|
**Ground rules (carry into every task):**
|
||||||
|
- Branch off `main` before any code change; never commit on `main`.
|
||||||
|
- Edit in place. Never touch `infra/*`. The `docker/*` cluster config is touched only if a task explicitly says so (none here do).
|
||||||
|
- Stage with explicit `git add <path>` — never `git add .`, never `git commit -am`.
|
||||||
|
- TDD: failing test → minimal code → green → commit. Full solution stays green (`dotnet build ScadaLink.slnx`, `dotnet test ScadaLink.slnx`).
|
||||||
|
- Additive message-contract evolution where possible; where a contract shape must change (Task 8), update every call site in the same task.
|
||||||
|
- Do not push to origin — the user authorizes pushes separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: Prep — feature branch
|
||||||
|
|
||||||
|
**Files:** none (git only).
|
||||||
|
|
||||||
|
**Step 1:** From a clean `main`, create the working branch:
|
||||||
|
```bash
|
||||||
|
git checkout main && git status --porcelain # expect clean
|
||||||
|
git checkout -b feature/audit-log-followups
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2:** Confirm baseline green:
|
||||||
|
```bash
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
```
|
||||||
|
Expected: build succeeds. (A full `dotnet test` baseline is optional but recommended.)
|
||||||
|
|
||||||
|
**Acceptance:** on branch `feature/audit-log-followups`, solution builds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Audit push — central ingest routing over ClusterClient
|
||||||
|
|
||||||
|
**What:** Make the receptionist-registered `CentralCommunicationActor` accept `IngestAuditEventsCommand` (and `IngestCachedTelemetryCommand`) from a site ClusterClient, forward to the `AuditLogIngestActor` cluster-singleton proxy, and pipe the reply back. Mirror the existing `NotificationSubmit` / `RegisterNotificationOutbox` pattern exactly.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs` — add `Receive<IngestAuditEventsCommand>` + `Receive<IngestCachedTelemetryCommand>` handlers; add a `RegisterAuditIngest` registration message handler holding the `AuditLogIngestActor` proxy `IActorRef` (mirror `RegisterNotificationOutbox` at line ~120 / `HandleNotificationSubmit` at line ~130).
|
||||||
|
- Create: `src/ScadaLink.Commons/Messages/Audit/RegisterAuditIngest.cs` — `public sealed record RegisterAuditIngest(IActorRef AuditIngestActor);` (mirror `RegisterNotificationOutbox`).
|
||||||
|
- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — after the central `AuditLogIngestActor` singleton + proxy are created (~lines 355–379), `Tell` the `RegisterAuditIngest` to the `CentralCommunicationActor` (mirror how the Notification Outbox proxy is registered).
|
||||||
|
- Test: `tests/ScadaLink.Communication.Tests/Actors/CentralCommunicationActorAuditTests.cs` (new).
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Handler `Ask`s the registered audit-ingest proxy and `PipeTo`s the `IngestAuditEventsReply` back to the original `Sender` (the ClusterClient round-trips it to the site). Use the existing audit-ingest Ask-timeout convention (30s — see `SiteStreamGrpcServer` `AuditIngestAskTimeout`); add a bound option if no constant is reachable.
|
||||||
|
- If no audit-ingest proxy is registered yet (startup race), reply with an empty `IngestAuditEventsReply([])` — the site keeps the rows `Pending` and retries, exactly as the gRPC handler does today.
|
||||||
|
- `IngestCachedTelemetryCommand` is routed the same way (its reply type is the same `IngestAuditEventsReply` per `AuditLogIngestActor`).
|
||||||
|
|
||||||
|
**Tests (TestKit + NSubstitute):**
|
||||||
|
1. `IngestAuditEventsCommand` with an audit-ingest probe registered → probe receives the command, actor replies the probe's `IngestAuditEventsReply` to the sender.
|
||||||
|
2. `IngestAuditEventsCommand` with no audit-ingest registered → sender gets `IngestAuditEventsReply` with empty `AcceptedEventIds`.
|
||||||
|
3. `IngestCachedTelemetryCommand` routes to the same proxy.
|
||||||
|
|
||||||
|
**Steps:** write failing tests → run (fail) → implement record + handlers + Host registration → run (pass) → `dotnet build ScadaLink.slnx` → commit.
|
||||||
|
|
||||||
|
**Commit:** `feat(communication): route audit ingest commands through CentralCommunicationActor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Audit push — real site client, Host wiring, integration test
|
||||||
|
|
||||||
|
**What:** Replace `NoOpSiteStreamAuditClient` (production binding) with a real `ISiteStreamAuditClient` that pushes over ClusterClient via the site's `SiteCommunicationActor`. After this task the site `auditlog.db` `Pending` backlog drains to central.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.AuditLog/Site/Telemetry/ClusterClientSiteAuditClient.cs` — implements `ISiteStreamAuditClient`; ctor takes the `SiteCommunicationActor` `IActorRef` + an Ask timeout.
|
||||||
|
- Modify: `src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs` — ensure `IngestAuditEventsCommand` / `IngestCachedTelemetryCommand` are forwarded over `ClusterClient.Send("/user/central-communication", …)` with the reply routed back to the Ask (mirror the `NotificationSubmit` forward at lines ~190/214/224).
|
||||||
|
- Modify: `src/ScadaLink.Host/Actors/AkkaHostedService.cs` — in the site telemetry wiring (~lines 648–681), construct `ClusterClientSiteAuditClient` with the `SiteCommunicationActor` ref and pass it to `SiteAuditTelemetryActor` instead of the DI-resolved `NoOpSiteStreamAuditClient`.
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs` (line ~124–129) — keep `NoOpSiteStreamAuditClient` as the DI default (it remains correct for central/test composition roots that have no `SiteCommunicationActor`); update the stale comment that says "M6's reconciliation work brings the real implementation".
|
||||||
|
- Test: `tests/ScadaLink.AuditLog.Tests/Site/Telemetry/ClusterClientSiteAuditClientTests.cs` (new); extend `tests/ScadaLink.IntegrationTests/AuditLog/` with a ClusterClient-push end-to-end test.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- `IngestAuditEventsAsync(AuditEventBatch, ct)` maps the batch to `IngestAuditEventsCommand(IReadOnlyList<AuditEvent>)`, `Ask`s the `SiteCommunicationActor` for `IngestAuditEventsReply`, maps the reply's `AcceptedEventIds` back into the `IngestAck` the `SiteAuditTelemetryActor` expects.
|
||||||
|
- An Ask timeout / failure must **throw** — `SiteAuditTelemetryActor`'s drain loop already treats a thrown exception as transient (rows stay `Pending`, retried next tick). Keep that contract.
|
||||||
|
- `IngestCachedTelemetryAsync` does the same with `IngestCachedTelemetryCommand`. (`CachedCallTelemetryForwarder` already resolves `ISiteStreamAuditClient` — no change there.)
|
||||||
|
- `AuditEvent` already crosses the wire as the `NotificationSubmit` records do; confirm the Akka serializer handles `IReadOnlyList<AuditEvent>` (notification messages prove the pattern).
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
1. `IngestAuditEventsAsync` → batch becomes one `IngestAuditEventsCommand`; mocked actor reply's accepted ids map onto `IngestAck`.
|
||||||
|
2. Partial ack (3 of 5 ids) → `IngestAck` lists only the 3.
|
||||||
|
3. Ask timeout → method throws (drain loop keeps rows `Pending`).
|
||||||
|
4. Integration: boot a site+central pair via the IntegrationTests harness, write an audit event on the site hot-path, assert a central `AuditLog` row appears within ~10s and the site row flips to `Forwarded`.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): real ClusterClient-based site audit push client`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Consolidate the duplicated audit DTO mappers
|
||||||
|
|
||||||
|
**What:** Collapse the 4 near-duplicate `AuditEvent`↔`AuditEventDto` mapping copies into one canonical mapper. The project-reference cycle (`AuditLog → Communication`, never the reverse) is resolved by hosting the canonical mapper **in `ScadaLink.Communication`** — it owns the generated `AuditEventDto` and references `Commons` for `AuditEvent`, and `AuditLog` already references `Communication`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs` — `public static class` with `ToDto(AuditEvent) → AuditEventDto` and `FromDto(AuditEventDto) → AuditEvent` (lift the canonical logic from `AuditLog/Telemetry/AuditEventMapper.cs`).
|
||||||
|
- Modify: `src/ScadaLink.Communication/Grpc/SiteStreamGrpcServer.cs` — replace the inlined `IngestAuditEvents` loop (~lines 265–295), `AuditEventToDto` (~490–517) and `MapAuditEventFromDto` (~537–561) with calls to `AuditEventDtoMapper`.
|
||||||
|
- Delete: `src/ScadaLink.AuditLog/Telemetry/AuditEventMapper.cs`; update its callers in `ScadaLink.AuditLog` to use `Communication`'s `AuditEventDtoMapper`.
|
||||||
|
- Leave untouched: `SqliteAuditWriter.MapRow` (SQLite `DataReader` → `AuditEvent`, not a DTO mapper — different source type) and `MapSiteCallFromDto` (SiteCall, not audit). Note this in the commit body.
|
||||||
|
- Test: move/merge `tests/ScadaLink.AuditLog.Tests/Telemetry/AuditEventMapperTests.cs` into `tests/ScadaLink.Communication.Tests/Grpc/AuditEventDtoMapperTests.cs`; keep round-trip coverage (`FromDto(ToDto(x)) == x`).
|
||||||
|
|
||||||
|
**Approach:** Pure refactor — no behaviour change. Verify field-by-field parity against all 3 inlined copies before deleting them (null handling, enum parsing, `Int32Value`/`Timestamp` wrapping).
|
||||||
|
|
||||||
|
**Steps:** create mapper + tests → run → swap call sites → delete old copies → `dotnet build` + `dotnet test ScadaLink.slnx` (all green, no behaviour drift) → commit.
|
||||||
|
|
||||||
|
**Commit:** `refactor(auditlog): consolidate AuditEvent DTO mappers into Communication`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Site Call Audit — query / KPI / detail backend
|
||||||
|
|
||||||
|
**What:** Build the missing read-side backend for the Site Calls UI: Commons message contracts, `SiteCallAuditActor` query/KPI/detail handlers, and `CommunicationService` methods. Mirror `NotificationOutboxQueries.cs` + the Notification Outbox actor/service shape. Spec: `Component-SiteCallAudit.md` §KPIs and §queryable list.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs` — records mirroring `NotificationOutboxQueries.cs`:
|
||||||
|
- `SiteCallQueryRequest` (CorrelationId, status/site/kind/target filters, date range, page cursor fields, PageSize)
|
||||||
|
- `SiteCallSummary` (TrackedOperationId, SourceSite, Kind, TargetSummary, Status, RetryCount, LastError, provenance, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc)
|
||||||
|
- `SiteCallQueryResponse` (CorrelationId, Success, ErrorMessage, IReadOnlyList<SiteCallSummary>, next-cursor fields)
|
||||||
|
- `SiteCallKpiRequest` / `SiteCallKpiResponse` (BufferedCount, ParkedCount, FailedLastInterval, DeliveredLastInterval, OldestPendingAge, StuckCount — mirror the Notification Outbox KPI shape; also a per-site variant)
|
||||||
|
- `SiteCallDetailRequest` / `SiteCallDetailResponse` / `SiteCallDetail` (full row incl. LastError, all timestamps).
|
||||||
|
- Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` — add `ReceiveAsync` handlers for the query / KPI / detail requests; query handler calls `ISiteCallAuditRepository.QueryAsync` (keyset paging on `(CreatedAtUtc DESC, TrackedOperationId DESC)`); KPI handler computes point-in-time counts from the `SiteCalls` table (stuck = `Pending`/`Retrying` older than the configurable threshold, default 10 min). Use the per-message DI scope pattern already in the actor.
|
||||||
|
- Add repo support if needed: `src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs` may need a KPI-count method + a detail `GetAsync` (a `GetAsync(TrackedOperationId)` already exists).
|
||||||
|
- Modify: `src/ScadaLink.Communication/CommunicationService.cs` — add `QuerySiteCallsAsync`, `GetSiteCallKpisAsync`, `GetPerSiteSiteCallKpisAsync`, `GetSiteCallDetailAsync` (mirror `QueryNotificationOutboxAsync` etc.: `Ask` the `SiteCallAuditActor` proxy with `_options.QueryTimeout`).
|
||||||
|
- Test: `tests/ScadaLink.SiteCallAudit.Tests/` (actor handlers), `tests/ScadaLink.Commons.Tests/` (contract shape), `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs` (extend for KPI counts).
|
||||||
|
|
||||||
|
**Commit:** `feat(sitecallaudit): query, KPI and detail backend for the Site Calls page`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Site Call Audit — Retry/Discard relay to owning site
|
||||||
|
|
||||||
|
**What:** Central UI Retry/Discard on a parked Site Call must relay `RetryParkedOperation` / `DiscardParkedOperation` to the **owning site** (sites are the source of truth — central never mutates the `SiteCalls` row directly; the corrected row arrives back via telemetry). Spec: `Component-SiteCallAudit.md` §actions-on-parked-rows.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Commons/Messages/Audit/SiteCallRelayMessages.cs` — `RetryParkedOperationRequest`/`Response`, `DiscardParkedOperationRequest`/`Response` (carry `TrackedOperationId`, `SourceSite`, `CorrelationId`; response carries Success + a "site unreachable" error case).
|
||||||
|
- Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` (or a small relay collaborator) — on a relay request, look up the owning site and forward `RetryParkedOperation`/`DiscardParkedOperation` to that site over the central→site ClusterClient (the central side already maintains one ClusterClient per site; reuse the `CentralCommunicationActor` site-addressing path). On no/late reply → respond "site unreachable".
|
||||||
|
- Modify: `src/ScadaLink.Communication/Actors/SiteCommunicationActor.cs` — receive `RetryParkedOperation`/`DiscardParkedOperation` and hand to the site operation-tracking subsystem.
|
||||||
|
- Modify the site operation-tracking owner (S&F operation-tracking store / `ParkedMessageHandlerActor` in `src/ScadaLink.StoreAndForward/`) — Retry resets a parked tracked operation to `Pending` for the retry loop; Discard marks it `Discarded`. Reuse the parked-message handling that already backs notification Retry/Discard.
|
||||||
|
- Modify: `src/ScadaLink.Communication/CommunicationService.cs` — add `RetrySiteCallAsync` / `DiscardSiteCallAsync`.
|
||||||
|
- Test: `tests/ScadaLink.SiteCallAudit.Tests/` (relay routing + unreachable path), `tests/ScadaLink.StoreAndForward.Tests/` (site-side parked op reset/discard), `tests/ScadaLink.Communication.Tests/`.
|
||||||
|
|
||||||
|
**Note for implementer:** this is the meatiest backend task — the central→site relay direction and the site-side parked-operation mutation are both required. If the site operation-tracking Retry/Discard primitive already exists for cached calls, reuse it; only add the message plumbing.
|
||||||
|
|
||||||
|
**Commit:** `feat(sitecallaudit): central→site Retry/Discard relay for parked operations`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Site Calls UI page + nav + Audit drill-in
|
||||||
|
|
||||||
|
**What:** Build the Central UI Site Calls page — a near-mirror of `NotificationReport.razor`. Spec: `Component-SiteCallAudit.md`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor` (+ `.razor.cs`) — route `@page "/site-calls/report"`, `RequireDeployment` (or `OperationalAudit`) auth to match the Notifications report gating. Structure (per the form-layout memory: header, filter card, results table, paging, modal):
|
||||||
|
- Filter card: Status, Kind, Source site, Target keyword, date range, "Stuck only" checkbox, Clear/Query.
|
||||||
|
- Results table columns: TrackedOperationId, Source site, Kind, Target, Status (badge + Stuck indicator), Retries, Last error, Created, Updated, Actions.
|
||||||
|
- Actions column: a **"View audit history"** link `href="/audit/log?correlationId=@row.TrackedOperationId"` (the `TrackedOperationId` is the audit `CorrelationId`) — mirror `NotificationReport.razor:172`; plus **Retry/Discard** buttons shown only on `Parked` rows (none on `Failed`).
|
||||||
|
- Keyset Previous/Next paging; double-click row → detail modal (body shows full row + LastError; reuse the Notifications detail-modal idiom — never `MarkupString`).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor` — register the Site Calls page (own "Site Calls" section, or under an existing group, consistent with the `Notifications` / `Audit` section pattern at lines ~65–129).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — confirm `?correlationId=` drill-in already covers this (it does); no change expected — just verify.
|
||||||
|
- Test: `tests/ScadaLink.CentralUI.Tests/Pages/` (bUnit — scaffold, paging, parked-only actions, drill-in link), `tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs` (new).
|
||||||
|
|
||||||
|
**Use the `frontend-design` skill** for page/component styling guidance. Blazor Server + Bootstrap only; custom components; clean corporate aesthetic.
|
||||||
|
|
||||||
|
**Commit:** `feat(centralui): Site Calls page with Retry/Discard and Audit drill-in`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Site Call KPI tiles + Health dashboard integration
|
||||||
|
|
||||||
|
**What:** Surface Site Call Audit KPIs on the Health dashboard, mirroring the Notification Outbox tiles + `AuditKpiTiles`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.CentralUI/Components/Health/SiteCallKpiTiles.razor` (+ `.razor.cs`) — mirror `Components/Health/AuditKpiTiles.razor`; tiles for Buffered, Parked (danger border if >0), Stuck (warning border if >0); each tile navigates to `/site-calls/report` with a query-string filter.
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor` (+ code-behind) — add a "Site Calls" KPI section between the Notification Outbox and Audit Log sections; load via `CommunicationService.GetSiteCallKpisAsync` (Task 4).
|
||||||
|
- Test: `tests/ScadaLink.CentralUI.Tests/` (bUnit — tile rendering, threshold borders, navigation targets).
|
||||||
|
|
||||||
|
**Commit:** `feat(centralui): Site Call KPI tiles on the Health dashboard`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Multi-value `AuditLogQueryFilter` — contract + repository
|
||||||
|
|
||||||
|
**What:** Widen `AuditLogQueryFilter` from single-value to multi-value on the `Channel`, `Kind`, `Status`, `SourceSiteId` dimensions, and translate them to `IN (...)` in the repository. `Target`, `Actor`, `CorrelationId`, `FromUtc`, `ToUtc` stay as-is. Keyset paging must not change.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — change `Channel`/`Kind`/`Status`/`SourceSiteId` to `IReadOnlyList<…>?` (e.g. `IReadOnlyList<AuditChannel>? Channels`). Keep the record's other params. This is a **breaking shape change** — update every call site in this task.
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` (`QueryAsync`, ~lines 119–165) — each widened dimension becomes `if (filter.Channels is { Count: > 0 }) query = query.Where(e => filter.Channels.Contains(e.Channel));`. Empty/null list = no filter. Keyset predicate + `OrderByDescending` untouched.
|
||||||
|
- Update all other `AuditLogQueryFilter` constructors in this task so the solution compiles (ManagementService `ParseFilter`, CentralUI `AuditQueryModel.ToFilter`, CLI helpers, tests) — the deep behaviour of those consumers is Task 9; here just make them compile (e.g. wrap a single value in a one-element list).
|
||||||
|
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — add `QueryAsync_FilterByMultipleChannels_ReturnsUnion`, multi-status, multi-site; keep the existing single-value and keyset tests green.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): multi-value AuditLogQueryFilter dimensions`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Multi-value filters — ManagementService, CLI, Central UI
|
||||||
|
|
||||||
|
**What:** Make the three consumers actually emit/accept multiple values per dimension instead of collapsing to the first.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` (`ParseFilter`, ~lines 369–414) — read repeated query params with `.ToArray()` (not `.ToString()`); parse each into the enum list; unparseable values silently dropped (keep the existing lax contract).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditQueryModel.cs` (`ToFilter`, ~lines 110–126) — stop collapsing to `.First()`; pass the full `Channels`/`Kinds`/`Statuses`/`SiteIdentifiers` sets. Adjust the `ErrorsOnly` logic (lines ~128–145) for multi-value `Status`. The chip UI already supports multi-select — no `.razor` change expected; verify.
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` export-URL builder (~lines 175–227) — emit repeated query-string params per selected value.
|
||||||
|
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` (~lines 29–41) — make `--channel`/`--kind`/`--status`/`--site` accept multiple values (System.CommandLine multi-arity options; keep `AcceptOnlyFromAmong` for the enum-like ones). Modify `src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs` — `AuditQueryArgs` fields become arrays; `BuildQueryString` emits one key per value.
|
||||||
|
- Test: extend `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`, `tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs`, `tests/ScadaLink.CentralUI.Tests/` filter-model tests for multi-value round-trips.
|
||||||
|
|
||||||
|
**Commit:** `feat(audit): multi-value filters across ManagementService, CLI and Central UI`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Audit results grid — column resize + reorder UX
|
||||||
|
|
||||||
|
**What:** Add drag-to-resize and drag-to-reorder column UX to `AuditResultsGrid`, persisted in `sessionStorage`. Blazor + Bootstrap + minimal JS interop only (no third-party libs).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js` — a `window.auditGrid` namespace: column-resize drag handlers, header drag-reorder handlers, and `save(key,json)` / `load(key)` over `sessionStorage` (mirror `treeview-storage.js`).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — render a resize handle in each `<th>`; make headers draggable; apply persisted widths (inline style/CSS var) and column order (the `ColumnOrder` parameter + `OrderedColumns()` already exist — wire it to persisted state); `IJSRuntime` calls to load on first render and save on change.
|
||||||
|
- Create: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.css` — resize-handle styling, drag-over feedback (mirror `AuditDrilldownDrawer.razor.css` / `TreeView.razor.css` idioms).
|
||||||
|
- Reference the script from the host page (`App.razor` / `_Host` / layout — match where `monaco-init.js` / `session-expiry.js` are referenced).
|
||||||
|
- Test: extend `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs` (or new `AuditGridColumnTests.cs`) — resize changes a column width, reorder changes header order, both survive a reload via `sessionStorage`.
|
||||||
|
|
||||||
|
**Use the `frontend-design` skill** for the resize-handle / drag-feedback visual treatment.
|
||||||
|
|
||||||
|
**Commit:** `feat(centralui): column resize and reorder for the audit results grid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final review
|
||||||
|
|
||||||
|
After Task 10: dispatch a final cross-cutting code review of the whole branch against this plan, then run the full solution build + test once more. Update `docs/plans/2026-05-20-audit-log-code-roadmap.md` header lines 14–19 to strike the five now-completed follow-ups (leaving the three v1.x items). Hand back to the user for the push decision (do not push).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task dependency summary
|
||||||
|
|
||||||
|
- Task 0 blocks everything.
|
||||||
|
- Task 2 blocked by Task 1.
|
||||||
|
- Task 3 independent (after Task 0).
|
||||||
|
- Task 5 blocked by Task 4.
|
||||||
|
- Task 6 blocked by Tasks 4 and 5.
|
||||||
|
- Task 7 blocked by Task 4.
|
||||||
|
- Task 9 blocked by Task 8.
|
||||||
|
- Task 10 independent (after Task 0).
|
||||||
|
|
||||||
|
Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → final review.
|
||||||
17
docs/plans/2026-05-21-audit-log-followups.md.tasks.json
Normal file
17
docs/plans/2026-05-21-audit-log-followups.md.tasks.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -121,11 +121,14 @@ public static class ServiceCollectionExtensions
|
|||||||
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
|
logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
|
||||||
filter: sp.GetRequiredService<IAuditPayloadFilter>()));
|
filter: sp.GetRequiredService<IAuditPayloadFilter>()));
|
||||||
|
|
||||||
// ISiteStreamAuditClient: NoOp default. M6's reconciliation work brings
|
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
|
||||||
// the real gRPC-backed implementation (no site→central gRPC channel
|
// central/test composition roots that have no SiteCommunicationActor.
|
||||||
// exists today — sites talk to central via Akka ClusterClient only).
|
// The real implementation is ClusterClientSiteAuditClient, which pushes
|
||||||
// Bundle H's integration test substitutes a stub directly into the
|
// audit telemetry to central over Akka ClusterClient via the site's
|
||||||
// SiteAuditTelemetryActor's Props.Create call.
|
// SiteCommunicationActor — the Host wires it directly into the
|
||||||
|
// SiteAuditTelemetryActor's Props.Create call for site roles (it cannot
|
||||||
|
// be a DI singleton because it needs the SiteCommunicationActor IActorRef,
|
||||||
|
// created during Akka bootstrap, not at DI-composition time).
|
||||||
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
|
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
|
||||||
|
|
||||||
// M3 Bundle F: site-side dual emitter for cached-call lifecycle
|
// M3 Bundle F: site-side dual emitter for cached-call lifecycle
|
||||||
|
|||||||
@@ -351,6 +351,54 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns up to <paramref name="limit"/> rows in
|
||||||
|
/// <see cref="AuditForwardState.Forwarded"/>, oldest
|
||||||
|
/// <see cref="AuditEvent.OccurredAtUtc"/> first, with
|
||||||
|
/// <see cref="AuditEvent.EventId"/> as the deterministic tiebreaker. The
|
||||||
|
/// <see cref="AuditForwardState.Forwarded"/>-specific counterpart of
|
||||||
|
/// <see cref="ReadPendingAsync"/>; used by tests to assert a row reached the
|
||||||
|
/// <see cref="AuditForwardState.Forwarded"/> state specifically (unlike
|
||||||
|
/// <see cref="ReadPendingSinceAsync"/>, which also returns
|
||||||
|
/// <see cref="AuditForwardState.Pending"/> rows).
|
||||||
|
/// </summary>
|
||||||
|
public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (limit <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror ReadPendingAsync: the write lock guards the single connection.
|
||||||
|
lock (_writeLock)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
using var cmd = _connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
||||||
|
FROM AuditLog
|
||||||
|
WHERE ForwardState = $forwarded
|
||||||
|
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||||
|
LIMIT $limit;
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$forwarded", AuditForwardState.Forwarded.ToString());
|
||||||
|
cmd.Parameters.AddWithValue("$limit", limit);
|
||||||
|
|
||||||
|
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
rows.Add(MapRow(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<AuditEvent>>(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Flips the supplied EventIds from <see cref="AuditForwardState.Pending"/> to
|
/// Flips the supplied EventIds from <see cref="AuditForwardState.Pending"/> to
|
||||||
/// <see cref="AuditForwardState.Forwarded"/> in a single UPDATE. Non-existent
|
/// <see cref="AuditForwardState.Forwarded"/> in a single UPDATE. Non-existent
|
||||||
|
|||||||
@@ -34,15 +34,17 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
|
|||||||
/// returns normally.
|
/// returns normally.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Wire push deferred to M6.</b> M3 keeps this forwarder synchronous
|
/// <b>Local-write only — the wire push is the drain actor's job.</b> This
|
||||||
/// against the local stores: there is no site→central gRPC channel yet, so
|
/// forwarder is deliberately synchronous against the two site-local SQLite
|
||||||
/// the <see cref="ISiteStreamAuditClient.IngestCachedTelemetryAsync"/> RPC
|
/// stores and never pushes to central itself. The site→central transport is
|
||||||
/// is registered on the interface (Bundle E1) but the production binding
|
/// now live: <c>ClusterClientSiteAuditClient</c> is the production binding of
|
||||||
/// remains <c>NoOpSiteStreamAuditClient</c>. Once M6 wires a real client the
|
/// <see cref="ISiteStreamAuditClient"/> on site roles (with
|
||||||
/// drain pattern from <c>SiteAuditTelemetryActor</c> can be reused — the
|
/// <c>NoOpSiteStreamAuditClient</c> retained only for central/test composition
|
||||||
/// <c>AuditEvent</c> rows already live in SQLite tagged
|
/// roots). The push happens out-of-band: <see cref="SiteAuditTelemetryActor"/>
|
||||||
/// <see cref="AuditForwardState.Pending"/>, so a single drain loop sweeps
|
/// sweeps the <c>AuditEvent</c> rows this forwarder wrote — they live in SQLite
|
||||||
/// both M2 and M3 emissions.
|
/// tagged <see cref="AuditForwardState.Pending"/> — and drains them to central
|
||||||
|
/// via that client. A single drain loop therefore covers both the audit-only
|
||||||
|
/// emissions and the cached-call emissions this forwarder produces.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
using ScadaLink.Communication.Grpc;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Site.Telemetry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="ISiteStreamAuditClient"/> binding for site composition
|
||||||
|
/// roots: pushes audit telemetry to central over Akka <c>ClusterClient</c> via
|
||||||
|
/// the site's <c>SiteCommunicationActor</c>. The actor forwards the command to
|
||||||
|
/// <c>/user/central-communication</c> and the central
|
||||||
|
/// <c>CentralCommunicationActor</c> Asks the <c>AuditLogIngestActor</c> proxy —
|
||||||
|
/// the same command/control transport notifications already use. Wired by the
|
||||||
|
/// Host for site roles; central and test composition roots keep the
|
||||||
|
/// <see cref="NoOpSiteStreamAuditClient"/> DI default (they have no
|
||||||
|
/// <c>SiteCommunicationActor</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Throw-on-failure contract.</b> An Ask timeout or a faulted reply
|
||||||
|
/// (<see cref="Status.Failure"/>) propagates as a thrown exception out of the
|
||||||
|
/// <c>Ingest*Async</c> methods — it is NOT caught and turned into an empty ack.
|
||||||
|
/// The <see cref="SiteAuditTelemetryActor"/> drain loop treats a thrown
|
||||||
|
/// exception as transient and leaves the rows <c>Pending</c> for the next tick.
|
||||||
|
/// Swallowing the fault into an empty ack would be indistinguishable from "zero
|
||||||
|
/// rows accepted" and would silently lose the retry signal. Task 1 confirmed
|
||||||
|
/// the central receiving end does not collapse an ingest fault into an empty
|
||||||
|
/// ack either, so a site-side Ask through the whole path faults cleanly on a
|
||||||
|
/// central-side timeout.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The batches arrive as proto DTOs (<see cref="AuditEventBatch"/> /
|
||||||
|
/// <see cref="CachedTelemetryBatch"/>) because the
|
||||||
|
/// <see cref="SiteAuditTelemetryActor"/> builds them with
|
||||||
|
/// <see cref="AuditEventDtoMapper.ToDto"/>. This client converts them back into
|
||||||
|
/// the <see cref="AuditEvent"/> / <see cref="SiteCall"/> entities the Akka
|
||||||
|
/// command messages carry — the same DTO→entity translation the
|
||||||
|
/// <c>SiteStreamGrpcServer</c> performs for the gRPC reconciliation path.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ClusterClientSiteAuditClient : ISiteStreamAuditClient
|
||||||
|
{
|
||||||
|
private readonly IActorRef _siteCommunicationActor;
|
||||||
|
private readonly TimeSpan _askTimeout;
|
||||||
|
|
||||||
|
/// <param name="siteCommunicationActor">
|
||||||
|
/// The site's <c>SiteCommunicationActor</c> — it forwards the ingest command
|
||||||
|
/// over the registered central ClusterClient and routes the reply back to
|
||||||
|
/// this client's Ask.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="askTimeout">
|
||||||
|
/// Ask timeout for the round-trip to central. On expiry the Ask throws
|
||||||
|
/// <see cref="Akka.Actor.AskTimeoutException"/>, which the drain loop treats
|
||||||
|
/// as transient (rows stay <c>Pending</c>).
|
||||||
|
/// </param>
|
||||||
|
public ClusterClientSiteAuditClient(IActorRef siteCommunicationActor, TimeSpan askTimeout)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(siteCommunicationActor);
|
||||||
|
_siteCommunicationActor = siteCommunicationActor;
|
||||||
|
_askTimeout = askTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(batch);
|
||||||
|
|
||||||
|
var events = new List<AuditEvent>(batch.Events.Count);
|
||||||
|
foreach (var dto in batch.Events)
|
||||||
|
{
|
||||||
|
events.Add(AuditEventDtoMapper.FromDto(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask<T> throws AskTimeoutException on timeout and rethrows a
|
||||||
|
// Status.Failure's inner cause — both surface as a thrown exception so
|
||||||
|
// the drain loop keeps the rows Pending. We deliberately do NOT catch.
|
||||||
|
var reply = await _siteCommunicationActor
|
||||||
|
.Ask<IngestAuditEventsReply>(new IngestAuditEventsCommand(events), _askTimeout, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return ToAck(reply.AcceptedEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(batch);
|
||||||
|
|
||||||
|
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
|
||||||
|
foreach (var packet in batch.Packets)
|
||||||
|
{
|
||||||
|
var audit = AuditEventDtoMapper.FromDto(packet.AuditEvent);
|
||||||
|
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
|
||||||
|
entries.Add(new CachedTelemetryEntry(audit, siteCall));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same throw-on-failure contract as IngestAuditEventsAsync. The reply
|
||||||
|
// type is IngestCachedTelemetryReply (the central dual-write reply),
|
||||||
|
// distinct from IngestAuditEventsReply.
|
||||||
|
var reply = await _siteCommunicationActor
|
||||||
|
.Ask<IngestCachedTelemetryReply>(new IngestCachedTelemetryCommand(entries), _askTimeout, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return ToAck(reply.AcceptedEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IngestAck ToAck(IReadOnlyList<Guid> acceptedEventIds)
|
||||||
|
{
|
||||||
|
var ack = new IngestAck();
|
||||||
|
foreach (var id in acceptedEventIds)
|
||||||
|
{
|
||||||
|
ack.AcceptedEventIds.Add(id.ToString());
|
||||||
|
}
|
||||||
|
return ack;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,40 +3,40 @@ using ScadaLink.Communication.Grpc;
|
|||||||
namespace ScadaLink.AuditLog.Site.Telemetry;
|
namespace ScadaLink.AuditLog.Site.Telemetry;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mockable abstraction over the central site-stream gRPC client surface that
|
/// Mockable abstraction over the central site-audit push surface that
|
||||||
/// <see cref="SiteAuditTelemetryActor"/> uses to push <see cref="AuditEventBatch"/>
|
/// <see cref="SiteAuditTelemetryActor"/> uses to forward <see cref="AuditEventBatch"/>
|
||||||
/// payloads. The production implementation (added in Bundle E host wiring)
|
/// payloads. The production implementation is
|
||||||
/// wraps the auto-generated <c>SiteStreamService.SiteStreamServiceClient</c>;
|
/// <see cref="ClusterClientSiteAuditClient"/> — a ClusterClient-based client,
|
||||||
/// unit tests substitute via NSubstitute against this interface so the actor
|
/// wired in the Host for site roles, that forwards batches to central via the
|
||||||
/// never needs a live gRPC channel.
|
/// site's <c>SiteCommunicationActor</c>. Unit tests substitute via NSubstitute
|
||||||
|
/// against this interface so the actor never needs a live transport.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ISiteStreamAuditClient
|
public interface ISiteStreamAuditClient
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pushes <paramref name="batch"/> to the central <c>IngestAuditEvents</c>
|
/// Forwards <paramref name="batch"/> to the central audit-ingest path. The
|
||||||
/// RPC. The returned <see cref="IngestAck"/> carries the
|
/// returned <see cref="IngestAck"/> carries the <c>accepted_event_ids</c>
|
||||||
/// <c>accepted_event_ids</c> the actor will flip to
|
/// the actor will flip to
|
||||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
|
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
|
||||||
/// in the site SQLite queue.
|
/// in the site SQLite queue.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
|
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pushes the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23 / M3)
|
/// Forwards the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23)
|
||||||
/// to the central <c>IngestCachedTelemetry</c> RPC. Each packet carries both
|
/// to the central cached-telemetry ingest path. Each packet carries both the
|
||||||
/// the audit row and the operational <c>SiteCalls</c> upsert; central writes
|
/// audit row and the operational <c>SiteCalls</c> upsert; central writes both
|
||||||
/// both in a single MS SQL transaction. Returns the same
|
/// in a single MS SQL transaction. Returns the same <see cref="IngestAck"/>
|
||||||
/// <see cref="IngestAck"/> shape as <see cref="IngestAuditEventsAsync"/> so
|
/// shape as <see cref="IngestAuditEventsAsync"/> so the site-side forwarder
|
||||||
/// the M3 site-side forwarder can flip the underlying audit rows to
|
/// can flip the underlying audit rows to
|
||||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
|
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
|
||||||
/// once central has acknowledged them.
|
/// once central has acknowledged them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The production gRPC-backed implementation lands in M6 (no site→central
|
/// The production <see cref="ClusterClientSiteAuditClient"/> forwards over
|
||||||
/// gRPC channel exists today); until then the default
|
/// the ClusterClient transport; the <see cref="NoOpSiteStreamAuditClient"/>
|
||||||
/// <see cref="NoOpSiteStreamAuditClient"/> binding returns an empty ack and
|
/// DI default (used by central and test composition roots) returns an empty
|
||||||
/// integration tests substitute a direct-actor client that routes the batch
|
/// ack so no rows are flipped.
|
||||||
/// straight into the in-process <c>AuditLogIngestActor</c>.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
|
Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,18 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default <see cref="ISiteStreamAuditClient"/> registered by
|
/// Default <see cref="ISiteStreamAuditClient"/> registered by
|
||||||
/// <see cref="ScadaLink.AuditLog.ServiceCollectionExtensions.AddAuditLog"/>.
|
/// <see cref="ScadaLink.AuditLog.ServiceCollectionExtensions.AddAuditLog"/>.
|
||||||
/// Ships with M2 site-sync-pipeline wiring; the real gRPC-backed
|
/// It is a no-op binding for composition roots that have no
|
||||||
/// implementation is deferred to M6 reconciliation, where a site→central gRPC
|
/// <c>SiteCommunicationActor</c> — central and test roots. Site roles override
|
||||||
/// channel will be introduced (no such channel exists today — sites talk to
|
/// it in the Host with the ClusterClient-based
|
||||||
/// central exclusively via Akka ClusterClient, while the gRPC SiteStreamService
|
/// <see cref="ClusterClientSiteAuditClient"/>, which actually forwards audit
|
||||||
/// is hosted on the SITE side for central→site streaming).
|
/// telemetry to central.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Returns an empty <see cref="IngestAck"/> so the
|
/// Returns an empty <see cref="IngestAck"/> so the
|
||||||
/// <see cref="SiteAuditTelemetryActor"/> doesn't flip any rows to
|
/// <see cref="SiteAuditTelemetryActor"/> doesn't flip any rows to
|
||||||
/// <c>Forwarded</c> when this NoOp is in effect — Bundle H's integration test
|
/// <c>Forwarded</c> when this NoOp is in effect — rows stay <c>Pending</c>
|
||||||
/// substitutes a stub client that routes directly to the central
|
/// until a real client (or a test stub) takes over.
|
||||||
/// <c>AuditLogIngestActor</c> in-process. Production wiring (M6) will replace
|
|
||||||
/// this binding with a real client.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Audit-write paths are best-effort by contract: a NoOp client keeps the
|
/// Audit-write paths are best-effort by contract: a NoOp client keeps the
|
||||||
@@ -35,7 +33,8 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(batch);
|
ArgumentNullException.ThrowIfNull(batch);
|
||||||
// Empty ack — no EventIds will be flipped to Forwarded, so rows stay
|
// Empty ack — no EventIds will be flipped to Forwarded, so rows stay
|
||||||
// Pending until M6's real client (or a Bundle H test stub) takes over.
|
// Pending until the real ClusterClientSiteAuditClient (or a test stub)
|
||||||
|
// takes over.
|
||||||
return Task.FromResult(EmptyAck);
|
return Task.FromResult(EmptyAck);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +42,10 @@ public sealed class NoOpSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
public Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
public Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(batch);
|
ArgumentNullException.ThrowIfNull(batch);
|
||||||
// Empty ack — same rationale as IngestAuditEventsAsync. The M3
|
// Empty ack — same rationale as IngestAuditEventsAsync. The site still
|
||||||
// CachedCallTelemetryForwarder still writes the audit + tracking rows to
|
// writes the audit + tracking rows to its SQLite stores authoritatively;
|
||||||
// the site SQLite stores authoritatively; central-side state only
|
// central-side state only materialises once the real
|
||||||
// materialises once M6's real gRPC client (or a Bundle G test stub) is
|
// ClusterClientSiteAuditClient (or a test stub) is wired in.
|
||||||
// wired in.
|
|
||||||
return Task.FromResult(EmptyAck);
|
return Task.FromResult(EmptyAck);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ScadaLink.AuditLog.Telemetry;
|
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Communication.Grpc;
|
using ScadaLink.Communication.Grpc;
|
||||||
@@ -136,7 +135,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
|
|||||||
var batch = new AuditEventBatch();
|
var batch = new AuditEventBatch();
|
||||||
foreach (var e in events)
|
foreach (var e in events)
|
||||||
{
|
{
|
||||||
batch.Events.Add(AuditEventMapper.ToDto(e));
|
batch.Events.Add(AuditEventDtoMapper.ToDto(e));
|
||||||
}
|
}
|
||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,16 +26,36 @@ public static class AuditCommands
|
|||||||
{
|
{
|
||||||
var sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
|
var sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
|
||||||
var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
|
var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
|
||||||
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound)" };
|
// --channel/--kind/--status/--site are multi-valued: System.CommandLine accepts
|
||||||
|
// both repeated tokens (--channel A --channel B) and, with
|
||||||
|
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
||||||
|
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
|
||||||
|
var channelOption = new Option<string[]>("--channel")
|
||||||
|
{
|
||||||
|
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
||||||
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve)" };
|
var kindOption = new Option<string[]>("--kind")
|
||||||
|
{
|
||||||
|
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
kindOption.AcceptOnlyFromAmong(
|
kindOption.AcceptOnlyFromAmong(
|
||||||
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
|
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
|
||||||
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
|
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
|
||||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped)" };
|
var statusOption = new Option<string[]>("--status")
|
||||||
|
{
|
||||||
|
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
statusOption.AcceptOnlyFromAmong(
|
statusOption.AcceptOnlyFromAmong(
|
||||||
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
|
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
|
||||||
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
|
var siteOption = new Option<string[]>("--site")
|
||||||
|
{
|
||||||
|
Description = "Filter by source site ID; repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
||||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||||
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
||||||
@@ -74,10 +94,10 @@ public static class AuditCommands
|
|||||||
{
|
{
|
||||||
Since = result.GetValue(sinceOption),
|
Since = result.GetValue(sinceOption),
|
||||||
Until = result.GetValue(untilOption),
|
Until = result.GetValue(untilOption),
|
||||||
Channel = result.GetValue(channelOption),
|
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
|
||||||
Kind = result.GetValue(kindOption),
|
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
|
||||||
Status = result.GetValue(statusOption),
|
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
|
||||||
Site = result.GetValue(siteOption),
|
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
|
||||||
Target = result.GetValue(targetOption),
|
Target = result.GetValue(targetOption),
|
||||||
Actor = result.GetValue(actorOption),
|
Actor = result.GetValue(actorOption),
|
||||||
CorrelationId = result.GetValue(correlationIdOption),
|
CorrelationId = result.GetValue(correlationIdOption),
|
||||||
@@ -108,10 +128,36 @@ public static class AuditCommands
|
|||||||
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
|
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
|
||||||
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
|
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
|
||||||
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
||||||
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel" };
|
// --channel/--kind/--status/--site are multi-valued — same shape as the
|
||||||
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" };
|
// `query` subcommand: repeated tokens (--channel A --channel B) and, with
|
||||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
|
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
||||||
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
|
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
|
||||||
|
var channelOption = new Option<string[]>("--channel")
|
||||||
|
{
|
||||||
|
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
|
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
||||||
|
var kindOption = new Option<string[]>("--kind")
|
||||||
|
{
|
||||||
|
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
|
kindOption.AcceptOnlyFromAmong(
|
||||||
|
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
|
||||||
|
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
|
||||||
|
var statusOption = new Option<string[]>("--status")
|
||||||
|
{
|
||||||
|
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
|
statusOption.AcceptOnlyFromAmong(
|
||||||
|
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
|
||||||
|
var siteOption = new Option<string[]>("--site")
|
||||||
|
{
|
||||||
|
Description = "Filter by source site ID; repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
|
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
|
||||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||||
|
|
||||||
@@ -142,10 +188,10 @@ public static class AuditCommands
|
|||||||
Until = result.GetValue(untilOption)!,
|
Until = result.GetValue(untilOption)!,
|
||||||
Format = result.GetValue(formatExportOption)!,
|
Format = result.GetValue(formatExportOption)!,
|
||||||
Output = result.GetValue(outputOption)!,
|
Output = result.GetValue(outputOption)!,
|
||||||
Channel = result.GetValue(channelOption),
|
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
|
||||||
Kind = result.GetValue(kindOption),
|
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
|
||||||
Status = result.GetValue(statusOption),
|
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
|
||||||
Site = result.GetValue(siteOption),
|
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
|
||||||
Target = result.GetValue(targetOption),
|
Target = result.GetValue(targetOption),
|
||||||
Actor = result.GetValue(actorOption),
|
Actor = result.GetValue(actorOption),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
|
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
|
||||||
/// Bundle B <c>GET /api/audit/export</c> parameters.
|
/// Bundle B <c>GET /api/audit/export</c> parameters.
|
||||||
|
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
|
||||||
|
/// are multi-valued — each supplied value becomes a repeated query-string param so
|
||||||
|
/// the server's multi-value <c>IN (…)</c> filter sees the full set, exactly like
|
||||||
|
/// the <c>audit query</c> subcommand.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuditExportArgs
|
public sealed class AuditExportArgs
|
||||||
{
|
{
|
||||||
@@ -13,10 +17,10 @@ public sealed class AuditExportArgs
|
|||||||
public string Until { get; set; } = string.Empty;
|
public string Until { get; set; } = string.Empty;
|
||||||
public string Format { get; set; } = string.Empty;
|
public string Format { get; set; } = string.Empty;
|
||||||
public string Output { get; set; } = string.Empty;
|
public string Output { get; set; } = string.Empty;
|
||||||
public string? Channel { get; set; }
|
public string[] Channel { get; set; } = Array.Empty<string>();
|
||||||
public string? Kind { get; set; }
|
public string[] Kind { get; set; } = Array.Empty<string>();
|
||||||
public string? Status { get; set; }
|
public string[] Status { get; set; } = Array.Empty<string>();
|
||||||
public string? Site { get; set; }
|
public string[] Site { get; set; } = Array.Empty<string>();
|
||||||
public string? Target { get; set; }
|
public string? Target { get; set; }
|
||||||
public string? Actor { get; set; }
|
public string? Actor { get; set; }
|
||||||
}
|
}
|
||||||
@@ -31,7 +35,11 @@ public static class AuditExportHelpers
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
|
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
|
||||||
/// time window + format, plus optional filters. Time-specs are resolved via
|
/// time window + format, plus optional filters. Time-specs are resolved via
|
||||||
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>.
|
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>. The multi-valued
|
||||||
|
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
|
||||||
|
/// repeated query-string key per value (e.g. <c>channel=A&channel=B</c>) so the
|
||||||
|
/// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
|
||||||
|
/// <see cref="AuditQueryHelpers.BuildQueryString"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
|
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
@@ -43,13 +51,21 @@ public static class AuditExportHelpers
|
|||||||
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AddEach(string key, IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
Add(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
|
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
|
||||||
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
|
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
|
||||||
Add("format", args.Format);
|
Add("format", args.Format);
|
||||||
Add("channel", args.Channel);
|
AddEach("channel", args.Channel);
|
||||||
Add("kind", args.Kind);
|
AddEach("kind", args.Kind);
|
||||||
Add("status", args.Status);
|
AddEach("status", args.Status);
|
||||||
Add("sourceSiteId", args.Site);
|
AddEach("sourceSiteId", args.Site);
|
||||||
Add("target", args.Target);
|
Add("target", args.Target);
|
||||||
Add("actor", args.Actor);
|
Add("actor", args.Actor);
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
|
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
|
||||||
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
|
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
|
||||||
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
|
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
|
||||||
|
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
|
||||||
|
/// are multi-valued — each supplied value becomes a repeated query-string param so
|
||||||
|
/// the server's multi-value <c>IN (…)</c> filter sees the full set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuditQueryArgs
|
public sealed class AuditQueryArgs
|
||||||
{
|
{
|
||||||
public string? Since { get; set; }
|
public string? Since { get; set; }
|
||||||
public string? Until { get; set; }
|
public string? Until { get; set; }
|
||||||
public string? Channel { get; set; }
|
public string[] Channel { get; set; } = Array.Empty<string>();
|
||||||
public string? Kind { get; set; }
|
public string[] Kind { get; set; } = Array.Empty<string>();
|
||||||
public string? Status { get; set; }
|
public string[] Status { get; set; } = Array.Empty<string>();
|
||||||
public string? Site { get; set; }
|
public string[] Site { get; set; } = Array.Empty<string>();
|
||||||
public string? Target { get; set; }
|
public string? Target { get; set; }
|
||||||
public string? Actor { get; set; }
|
public string? Actor { get; set; }
|
||||||
public string? CorrelationId { get; set; }
|
public string? CorrelationId { get; set; }
|
||||||
@@ -73,8 +76,11 @@ public static class AuditQueryHelpers
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
|
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
|
||||||
/// args plus an optional keyset cursor. Unset filters are omitted. <c>--errors-only</c>
|
/// args plus an optional keyset cursor. Unset filters are omitted. The multi-valued
|
||||||
/// maps to <c>status=Failed</c> (the server takes a single status value).
|
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
|
||||||
|
/// repeated query-string key per value (e.g. <c>channel=A&channel=B</c>) so the
|
||||||
|
/// server's multi-value <c>IN (…)</c> filter receives the full set. <c>--errors-only</c>
|
||||||
|
/// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string BuildQueryString(
|
public static string BuildQueryString(
|
||||||
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
|
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
|
||||||
@@ -87,20 +93,35 @@ public static class AuditQueryHelpers
|
|||||||
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AddEach(string key, IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
Add(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(args.Since))
|
if (!string.IsNullOrWhiteSpace(args.Since))
|
||||||
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
|
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
|
||||||
if (!string.IsNullOrWhiteSpace(args.Until))
|
if (!string.IsNullOrWhiteSpace(args.Until))
|
||||||
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
|
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
Add("channel", args.Channel);
|
AddEach("channel", args.Channel);
|
||||||
Add("kind", args.Kind);
|
AddEach("kind", args.Kind);
|
||||||
|
|
||||||
// --errors-only is a convenience shorthand for the single-value Failed status
|
// --errors-only is a convenience shorthand for the Failed status filter. The
|
||||||
// filter. The server's status filter accepts one value, so --errors-only and an
|
// server's status filter is multi-value, but --errors-only stays a single-status
|
||||||
// explicit --status are mutually exclusive in effect; --errors-only wins.
|
// override: it pins status=Failed and supersedes any explicit --status values.
|
||||||
Add("status", args.ErrorsOnly ? "Failed" : args.Status);
|
if (args.ErrorsOnly)
|
||||||
|
{
|
||||||
|
Add("status", "Failed");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AddEach("status", args.Status);
|
||||||
|
}
|
||||||
|
|
||||||
Add("sourceSiteId", args.Site);
|
AddEach("sourceSiteId", args.Site);
|
||||||
Add("target", args.Target);
|
Add("target", args.Target);
|
||||||
Add("actor", args.Actor);
|
Add("actor", args.Actor);
|
||||||
Add("correlationId", args.CorrelationId);
|
Add("correlationId", args.CorrelationId);
|
||||||
|
|||||||
@@ -1078,10 +1078,10 @@ scadalink --url <url> audit query [options]
|
|||||||
|--------|----------|---------|-------------|
|
|--------|----------|---------|-------------|
|
||||||
| `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
| `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||||
| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
|
||||||
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`) |
|
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`); repeatable — multiple values are OR-combined |
|
||||||
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`) |
|
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`); repeatable — multiple values are OR-combined |
|
||||||
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`) |
|
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`); repeatable — multiple values are OR-combined |
|
||||||
| `--site` | no | — | Filter by source site ID |
|
| `--site` | no | — | Filter by source site ID; repeatable — multiple values are OR-combined |
|
||||||
| `--target` | no | — | Filter by target (external system, DB connection, notification list) |
|
| `--target` | no | — | Filter by target (external system, DB connection, notification list) |
|
||||||
| `--actor` | no | — | Filter by actor |
|
| `--actor` | no | — | Filter by actor |
|
||||||
| `--correlation-id` | no | — | Filter by correlation ID |
|
| `--correlation-id` | no | — | Filter by correlation ID |
|
||||||
@@ -1090,6 +1090,11 @@ scadalink --url <url> audit query [options]
|
|||||||
| `--all` | no | `false` | Fetch every page, following the keyset cursor |
|
| `--all` | no | `false` | Fetch every page, following the keyset cursor |
|
||||||
| `--format` | no | `json` | Output format: `json` (JSONL, one event per line) or `table` |
|
| `--format` | no | `json` | Output format: `json` (JSONL, one event per line) or `table` |
|
||||||
|
|
||||||
|
The `--channel`/`--kind`/`--status`/`--site` filters accept multiple values —
|
||||||
|
either as repeated flags (`--channel ApiOutbound --channel DbOutbound`) or
|
||||||
|
space-separated after one flag (`--channel ApiOutbound DbOutbound`). Values
|
||||||
|
within one filter are OR-combined; the different filters are AND-combined.
|
||||||
|
|
||||||
With `--format table`, events render as an aligned text table with columns
|
With `--format table`, events render as an aligned text table with columns
|
||||||
`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`,
|
`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`,
|
||||||
`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With
|
`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With
|
||||||
|
|||||||
@@ -74,34 +74,27 @@ public static class AuditExportEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>.
|
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
|
||||||
/// Unknown enum names / un-parseable Guids / dates are silently dropped
|
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c> dimensions are
|
||||||
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>).
|
/// multi-value: a repeated query param yields a multi-element filter list, a
|
||||||
|
/// single param a one-element list. Unknown enum names / un-parseable Guids /
|
||||||
|
/// dates are silently dropped (same lax contract as
|
||||||
|
/// <c>AuditLogPage.ApplyQueryStringFilters</c>) — an unparseable value within
|
||||||
|
/// a repeated set is dropped, not the whole set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This endpoint reads the source-site filter from the <c>site</c> query key,
|
||||||
|
/// whereas the ManagementService export endpoint reads it as
|
||||||
|
/// <c>sourceSiteId</c>. The divergence is deliberate — each endpoint matches
|
||||||
|
/// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
|
||||||
|
/// </remarks>
|
||||||
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||||
{
|
{
|
||||||
AuditChannel? channel = null;
|
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
|
||||||
if (query.TryGetValue("channel", out var channelValues)
|
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
|
||||||
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
|
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
|
||||||
{
|
var sites = AuditQueryParamParsers.ParseStringList(query["site"]);
|
||||||
channel = parsedChannel;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuditKind? kind = null;
|
|
||||||
if (query.TryGetValue("kind", out var kindValues)
|
|
||||||
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
|
|
||||||
{
|
|
||||||
kind = parsedKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuditStatus? status = null;
|
|
||||||
if (query.TryGetValue("status", out var statusValues)
|
|
||||||
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
|
|
||||||
{
|
|
||||||
status = parsedStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
string? site = TrimToNullable(query, "site");
|
|
||||||
string? target = TrimToNullable(query, "target");
|
string? target = TrimToNullable(query, "target");
|
||||||
string? actor = TrimToNullable(query, "actor");
|
string? actor = TrimToNullable(query, "actor");
|
||||||
|
|
||||||
@@ -116,10 +109,10 @@ public static class AuditExportEndpoints
|
|||||||
DateTime? toUtc = ParseUtcDate(query, "to");
|
DateTime? toUtc = ParseUtcDate(query, "to");
|
||||||
|
|
||||||
return new AuditLogQueryFilter(
|
return new AuditLogQueryFilter(
|
||||||
Channel: channel,
|
Channels: channels,
|
||||||
Kind: kind,
|
Kinds: kinds,
|
||||||
Status: status,
|
Statuses: statuses,
|
||||||
SourceSiteId: site,
|
SourceSiteIds: sites,
|
||||||
Target: target,
|
Target: target,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
CorrelationId: correlationId,
|
CorrelationId: correlationId,
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is single-value
|
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is multi-value
|
||||||
/// per dimension today; the chip multi-selects therefore collapse to the FIRST
|
/// per dimension: the chip multi-selects map straight through to the
|
||||||
/// selected chip when the model is published via <see cref="ToFilter"/>. That is a
|
/// <c>Channels</c> / <c>Kinds</c> / <c>Statuses</c> / <c>SourceSiteIds</c> filter
|
||||||
/// deliberate Bundle B scope decision — the chip UI is preserved so a follow-up can
|
/// lists when the model is published via <see cref="ToFilter"/> — an empty set means
|
||||||
/// either repeat the query per chip or widen the filter contract without rewriting
|
/// "do not constrain". Instance and Script free-text remain UI-only: the underlying
|
||||||
/// the form. Instance and Script free-text are also UI-only today: the underlying
|
/// filter has no matching columns, so they are dropped when the model is published.
|
||||||
/// filter has no matching columns, so they are dropped during collapse.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The Errors-only toggle is a convenience: when true AND no explicit Status chips
|
/// The Errors-only toggle is a convenience: when true AND no explicit Status chips
|
||||||
/// are selected, the collapsed filter pins <see cref="AuditStatus.Failed"/> (the
|
/// are selected, <see cref="ToFilter"/> targets the full error-status set
|
||||||
/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle
|
/// {<see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
|
||||||
/// is a no-op — the explicit Status filter wins.
|
/// <see cref="AuditStatus.Discarded"/>}. When Status chips ARE selected the toggle
|
||||||
|
/// is a no-op — the explicit Status chips win.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuditQueryModel
|
public sealed class AuditQueryModel
|
||||||
@@ -104,20 +104,21 @@ public sealed class AuditQueryModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collapses this UI model to the repository's single-value filter.
|
/// Publishes this UI model as the repository's multi-value filter: each chip
|
||||||
/// See class doc for the multi-select → single-value contract.
|
/// multi-select maps straight through to its filter list (an empty set yields
|
||||||
|
/// <c>null</c> — "do not constrain"). See class doc for the Errors-only rule.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AuditLogQueryFilter ToFilter(DateTime utcNow)
|
public AuditLogQueryFilter ToFilter(DateTime utcNow)
|
||||||
{
|
{
|
||||||
var status = ResolveStatus();
|
var statuses = ResolveStatuses();
|
||||||
|
|
||||||
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
|
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
|
||||||
|
|
||||||
return new AuditLogQueryFilter(
|
return new AuditLogQueryFilter(
|
||||||
Channel: Channels.Count > 0 ? Channels.First() : null,
|
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
|
||||||
Kind: Kinds.Count > 0 ? Kinds.First() : null,
|
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
|
||||||
Status: status,
|
Statuses: statuses,
|
||||||
SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null,
|
SourceSiteIds: SiteIdentifiers.Count > 0 ? SiteIdentifiers.ToArray() : null,
|
||||||
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
|
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
|
||||||
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
|
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
|
||||||
CorrelationId: null,
|
CorrelationId: null,
|
||||||
@@ -125,20 +126,22 @@ public sealed class AuditQueryModel
|
|||||||
ToUtc: toUtc);
|
ToUtc: toUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuditStatus? ResolveStatus()
|
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>
|
||||||
|
private static readonly AuditStatus[] ErrorStatuses =
|
||||||
|
{ AuditStatus.Failed, AuditStatus.Parked, AuditStatus.Discarded };
|
||||||
|
|
||||||
|
private IReadOnlyList<AuditStatus>? ResolveStatuses()
|
||||||
{
|
{
|
||||||
if (Statuses.Count > 0)
|
if (Statuses.Count > 0)
|
||||||
{
|
{
|
||||||
// Explicit chips win — Errors-only is a no-op.
|
// Explicit chips win — Errors-only is a no-op.
|
||||||
return Statuses.First();
|
return Statuses.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ErrorsOnly)
|
if (ErrorsOnly)
|
||||||
{
|
{
|
||||||
// Single-value filter contract: Failed is the lead non-success status.
|
// Multi-value filter: Errors-only targets the full non-success set.
|
||||||
// When the filter widens to multi-value the full {Failed, Parked, Discarded}
|
return ErrorStatuses;
|
||||||
// set will flow through.
|
|
||||||
return AuditStatus.Failed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -12,12 +12,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover align-middle">
|
<table class="table table-sm table-hover align-middle" @ref="_tableRef">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@foreach (var col in OrderedColumns())
|
@foreach (var col in OrderedColumns())
|
||||||
{
|
{
|
||||||
<th data-test="col-header-@col.Key">@col.Label</th>
|
// @key keeps Blazor reusing one DOM node per column across
|
||||||
|
// re-renders (reorder/resize), so audit-grid.js binds drag
|
||||||
|
// listeners exactly once per <th> and never leaks them onto
|
||||||
|
// discarded nodes — the __auditGridCellBound guard relies on
|
||||||
|
// this node stability to be fully sound.
|
||||||
|
<th class="audit-grid-th"
|
||||||
|
@key="col.Key"
|
||||||
|
data-test="col-header-@col.Key"
|
||||||
|
data-col-key="@col.Key"
|
||||||
|
style="@ColumnWidthStyle(col.Key)">
|
||||||
|
@col.Label
|
||||||
|
<span class="audit-grid-resize-handle"
|
||||||
|
data-test="col-resize-@col.Key"
|
||||||
|
aria-hidden="true"></span>
|
||||||
|
</th>
|
||||||
}
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -48,7 +62,7 @@
|
|||||||
@onclick="() => HandleRowClick(row)">
|
@onclick="() => HandleRowClick(row)">
|
||||||
@foreach (var col in OrderedColumns())
|
@foreach (var col in OrderedColumns())
|
||||||
{
|
{
|
||||||
<td>
|
<td class="audit-grid-td" style="@ColumnWidthStyle(col.Key)">
|
||||||
@RenderCell(col.Key, row)
|
@RenderCell(col.Key, row)
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
@@ -14,12 +16,15 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
/// source without standing up EF Core.
|
/// source without standing up EF Core.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Column model.</b> Each column has a stable string key; the visible order
|
/// <b>Column model.</b> Each column has a stable string key. The default
|
||||||
/// is the <see cref="ColumnOrder"/> parameter. M7 scope: the column-model
|
/// visible order is the <see cref="ColumnOrder"/> parameter (or the spec
|
||||||
/// framework is in place but resize / drag-reorder UX is intentionally NOT
|
/// order from Component-AuditLog.md §10 when the parameter is null). On top of
|
||||||
/// implemented — the full spec calls for persisted-per-user reordering and
|
/// that default the grid layers a per-browser override: drag-to-reorder and
|
||||||
/// resizing, which M7.x can ship without rewriting the column model. Resizing
|
/// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column
|
||||||
/// today is CSS-based via Bootstrap's <c>.table-responsive</c> wrapper.
|
/// widths to <c>sessionStorage</c>, and the grid restores them on first
|
||||||
|
/// render. A stored order that names an unknown/removed column degrades
|
||||||
|
/// gracefully — unknown keys are dropped, missing columns appended in default
|
||||||
|
/// order — so it never throws.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -32,11 +37,28 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
|
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
|
||||||
/// end" signal for keyset paging without a count query.
|
/// end" signal for keyset paging without a count query.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Accessibility.</b> Column resize and reorder are mouse/pointer-only —
|
||||||
|
/// they use a pointer-driven resize handle and native HTML5 drag-and-drop with
|
||||||
|
/// no keyboard equivalent and no ARIA for the reorder. This is a conscious
|
||||||
|
/// scope decision for an internal tool, not an oversight: only the column-
|
||||||
|
/// <i>customisation</i> gesture is mouse-only. The persisted layout itself
|
||||||
|
/// renders as plain HTML, so keyboard and assistive-technology users still get
|
||||||
|
/// a fully readable, navigable grid.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AuditResultsGrid
|
public partial class AuditResultsGrid : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private const int DefaultPageSize = 100;
|
private const int DefaultPageSize = 100;
|
||||||
|
|
||||||
|
/// <summary>Minimum persisted column width — mirrors <c>auditGrid.minWidth</c>.</summary>
|
||||||
|
private const int MinColumnWidthPx = 64;
|
||||||
|
|
||||||
|
/// <summary>sessionStorage keys (namespaced under <c>auditGrid:</c> by the JS helper).</summary>
|
||||||
|
private const string ColumnOrderStorageKey = "columnOrder";
|
||||||
|
private const string ColumnWidthsStorageKey = "columnWidths";
|
||||||
|
|
||||||
private readonly List<AuditEvent> _rows = new();
|
private readonly List<AuditEvent> _rows = new();
|
||||||
private int _pageNumber = 1;
|
private int _pageNumber = 1;
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
@@ -44,6 +66,18 @@ public partial class AuditResultsGrid
|
|||||||
|
|
||||||
private AuditLogQueryFilter? _activeFilter;
|
private AuditLogQueryFilter? _activeFilter;
|
||||||
|
|
||||||
|
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||||
|
|
||||||
|
private ElementReference _tableRef;
|
||||||
|
private DotNetObjectReference<AuditResultsGrid>? _selfRef;
|
||||||
|
|
||||||
|
// Effective column state. _columnOrder is the live display order (seeded
|
||||||
|
// from the ColumnOrder parameter / spec default, then overridden by any
|
||||||
|
// persisted sessionStorage order). _columnWidths holds per-key pixel
|
||||||
|
// widths from a prior resize; absent keys render at auto width.
|
||||||
|
private List<string>? _columnOrder;
|
||||||
|
private readonly Dictionary<string, int> _columnWidths = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filter to apply. When this parameter changes the grid resets to page 1 and
|
/// Filter to apply. When this parameter changes the grid resets to page 1 and
|
||||||
/// reissues the query — that's the contract the parent page relies on so the
|
/// reissues the query — that's the contract the parent page relies on so the
|
||||||
@@ -75,6 +109,9 @@ public partial class AuditResultsGrid
|
|||||||
/// <c>data-test</c> + the column-order parameter); the label is the user-facing
|
/// <c>data-test</c> + the column-order parameter); the label is the user-facing
|
||||||
/// header text. Mirrors Component-AuditLog.md §10.
|
/// header text. Mirrors Component-AuditLog.md §10.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// Label intentionally equals Key for every column today; the separate Label
|
||||||
|
// field is future-proofing for humanised headers (e.g. "Occurred (UTC)") —
|
||||||
|
// populating it is a deliberate later change, out of scope here.
|
||||||
private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
|
private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
|
||||||
{
|
{
|
||||||
("OccurredAtUtc", "OccurredAtUtc"),
|
("OccurredAtUtc", "OccurredAtUtc"),
|
||||||
@@ -90,24 +127,57 @@ public partial class AuditResultsGrid
|
|||||||
};
|
};
|
||||||
|
|
||||||
private IReadOnlyList<(string Key, string Label)> OrderedColumns()
|
private IReadOnlyList<(string Key, string Label)> OrderedColumns()
|
||||||
|
=> ResolveOrder(_columnOrder ?? ColumnOrder);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a candidate list of column keys into the concrete display
|
||||||
|
/// columns. Degrades gracefully so a stale persisted order is never fatal:
|
||||||
|
/// unknown keys are dropped, and any column not named in the candidate
|
||||||
|
/// list is appended in its default (spec) position. A null/empty candidate
|
||||||
|
/// yields the full default order.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList<string>? candidate)
|
||||||
{
|
{
|
||||||
if (ColumnOrder is null || ColumnOrder.Count == 0)
|
if (candidate is null || candidate.Count == 0)
|
||||||
{
|
{
|
||||||
return AllColumns;
|
return AllColumns;
|
||||||
}
|
}
|
||||||
|
|
||||||
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
|
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
|
||||||
var ordered = new List<(string Key, string Label)>(ColumnOrder.Count);
|
var ordered = new List<(string Key, string Label)>(AllColumns.Count);
|
||||||
foreach (var key in ColumnOrder)
|
var seen = new HashSet<string>();
|
||||||
|
foreach (var key in candidate)
|
||||||
{
|
{
|
||||||
if (byKey.TryGetValue(key, out var col))
|
// Drop unknown keys (removed/renamed columns) and any duplicates.
|
||||||
|
if (byKey.TryGetValue(key, out var col) && seen.Add(key))
|
||||||
{
|
{
|
||||||
ordered.Add(col);
|
ordered.Add(col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ordered.Count == 0 ? AllColumns : ordered;
|
|
||||||
|
// Append any columns the candidate omitted, in default order, so a
|
||||||
|
// newly-added column still appears after a restore of an older order.
|
||||||
|
foreach (var col in AllColumns)
|
||||||
|
{
|
||||||
|
if (seen.Add(col.Key))
|
||||||
|
{
|
||||||
|
ordered.Add(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline style for a column's cells: emits the <c>--audit-col-width</c>
|
||||||
|
/// custom property the scoped stylesheet reads, or an empty string when
|
||||||
|
/// the column has no persisted width (auto layout).
|
||||||
|
/// </summary>
|
||||||
|
private string ColumnWidthStyle(string key)
|
||||||
|
=> _columnWidths.TryGetValue(key, out var width)
|
||||||
|
? $"--audit-col-width: {width}px;"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter
|
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter
|
||||||
@@ -180,6 +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 <th> nodes plus the
|
||||||
|
// __auditGridCellBound guard mean init re-scans the header and rebinds
|
||||||
|
// nothing — so there is deliberately no gating logic here.
|
||||||
|
if (_selfRef is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("auditGrid.init", _tableRef, _selfRef);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// Circuit gone before init completed — nothing to wire.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the persisted column order + widths from <c>sessionStorage</c> and
|
||||||
|
/// applies them. A missing, empty, or corrupt payload is treated as "no
|
||||||
|
/// prior state" — the grid keeps its default order/widths and never throws.
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadPersistedStateAsync()
|
||||||
|
{
|
||||||
|
var orderJson = await TryLoadAsync(ColumnOrderStorageKey);
|
||||||
|
var widthsJson = await TryLoadAsync(ColumnWidthsStorageKey);
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(orderJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stored = JsonSerializer.Deserialize<List<string>>(orderJson);
|
||||||
|
if (stored is { Count: > 0 })
|
||||||
|
{
|
||||||
|
// Normalise through ResolveOrder so a stale key never sticks.
|
||||||
|
_columnOrder = ResolveOrder(stored).Select(c => c.Key).ToList();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Corrupt payload — ignore, keep the default order.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(widthsJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stored = JsonSerializer.Deserialize<Dictionary<string, int>>(widthsJson);
|
||||||
|
if (stored is not null)
|
||||||
|
{
|
||||||
|
var validKeys = AllColumns.Select(c => c.Key).ToHashSet();
|
||||||
|
_columnWidths.Clear();
|
||||||
|
foreach (var (key, width) in stored)
|
||||||
|
{
|
||||||
|
// Drop widths for unknown columns; clamp to the minimum.
|
||||||
|
if (validKeys.Contains(key))
|
||||||
|
{
|
||||||
|
_columnWidths[key] = Math.Max(MinColumnWidthPx, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = _columnWidths.Count > 0 || changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Corrupt payload — ignore, keep auto widths.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryLoadAsync(string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await JS.InvokeAsync<string?>("auditGrid.load", key);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JS callback: the user finished resizing a column. Persists the new
|
||||||
|
/// per-column width and re-renders so the body cells track the header.
|
||||||
|
/// </summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnColumnResized(string columnKey, int widthPx)
|
||||||
|
{
|
||||||
|
if (!AllColumns.Any(c => c.Key == columnKey))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_columnWidths[columnKey] = Math.Max(MinColumnWidthPx, widthPx);
|
||||||
|
await SaveAsync(ColumnWidthsStorageKey, JsonSerializer.Serialize(_columnWidths));
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JS callback: the user dropped column <paramref name="fromKey"/> onto the
|
||||||
|
/// header of <paramref name="toKey"/>. Moves the dragged column into the
|
||||||
|
/// target's slot, persists the resulting order, and re-renders.
|
||||||
|
/// </summary>
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task OnColumnReordered(string fromKey, string toKey)
|
||||||
|
{
|
||||||
|
// Start from the current effective order so successive drags compose.
|
||||||
|
var order = OrderedColumns().Select(c => c.Key).ToList();
|
||||||
|
var fromIndex = order.IndexOf(fromKey);
|
||||||
|
var toIndex = order.IndexOf(toKey);
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
order.RemoveAt(fromIndex);
|
||||||
|
// After the removal the target index shifts left by one when the
|
||||||
|
// dragged column originally sat before it.
|
||||||
|
if (fromIndex < toIndex)
|
||||||
|
{
|
||||||
|
toIndex--;
|
||||||
|
}
|
||||||
|
order.Insert(toIndex, fromKey);
|
||||||
|
|
||||||
|
_columnOrder = order;
|
||||||
|
await SaveAsync(ColumnOrderStorageKey, JsonSerializer.Serialize(order));
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync(string key, string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("auditGrid.save", key, json);
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// Circuit gone — the in-memory state still drives this render.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_selfRef?.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private static string StatusBadgeClass(AuditStatus status) => status switch
|
private static string StatusBadgeClass(AuditStatus status) => status switch
|
||||||
{
|
{
|
||||||
AuditStatus.Delivered => "badge bg-success",
|
AuditStatus.Delivered => "badge bg-success",
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* Audit results grid — column resize + reorder UX (#23 follow-ups Task 10).
|
||||||
|
The base .table classes come from Bootstrap; the rules below add the
|
||||||
|
resize-handle affordance and the drag-to-reorder drop feedback. The
|
||||||
|
interaction itself lives in wwwroot/js/audit-grid.js — this file is purely
|
||||||
|
the visual treatment. Internal-tool aesthetic: subtle, no flashy motion. */
|
||||||
|
|
||||||
|
/* A persisted width is delivered as the --audit-col-width custom property on
|
||||||
|
the <th> and matching <td> cells (set inline by the component / by
|
||||||
|
audit-grid.js during a drag). When present it pins the cell; when absent
|
||||||
|
the column falls back to Bootstrap auto-layout. The body cells also clip
|
||||||
|
overflowing text so a narrowed column stays tidy. */
|
||||||
|
.audit-grid-th[style*="--audit-col-width"],
|
||||||
|
.audit-grid-td[style*="--audit-col-width"] {
|
||||||
|
width: var(--audit-col-width);
|
||||||
|
min-width: var(--audit-col-width);
|
||||||
|
max-width: var(--audit-col-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-grid-td[style*="--audit-col-width"] {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The header cell hosts the resize handle on its right edge, so it must be a
|
||||||
|
positioning context. Padding on the right is trimmed so the 6px handle does
|
||||||
|
not crowd the label text. */
|
||||||
|
.audit-grid-th {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
/* The whole header is draggable for reorder — a grab cursor signals it. */
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-grid-th:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* V — resize handle. A thin invisible hit-strip on the right edge: 6px wide
|
||||||
|
for a comfortable grab target, transparent at rest so the header reads
|
||||||
|
clean. On hover a hairline primary rule fades in via the inset box-shadow
|
||||||
|
so the affordance is discoverable without being visually noisy. */
|
||||||
|
.audit-grid-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
/* Sit above the draggable header so a resize never starts a reorder. */
|
||||||
|
z-index: 1;
|
||||||
|
transition: box-shadow 0.08s linear, background-color 0.08s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-grid-resize-handle:hover {
|
||||||
|
/* Hairline rule centred on the strip's right edge. */
|
||||||
|
box-shadow: inset -2px 0 0 -1px rgba(var(--bs-primary-rgb), 0.55);
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* While a drag-resize is in progress the column gets a steady primary rule on
|
||||||
|
its right edge so the user keeps a clear visual anchor. */
|
||||||
|
.audit-grid-th.resizing {
|
||||||
|
box-shadow: inset -2px 0 0 0 var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-grid-th.resizing .audit-grid-resize-handle {
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* V — reorder feedback. The dragged header dims slightly; the prospective
|
||||||
|
drop target gets a left-edge accent rule + a faint info wash, matching the
|
||||||
|
TreeView drop-target idiom (a quiet, unmistakable cue, not an animation). */
|
||||||
|
.audit-grid-th.dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-grid-th.drop-target {
|
||||||
|
background-color: rgba(var(--bs-info-rgb), 0.18);
|
||||||
|
box-shadow: inset 2px 0 0 0 var(--bs-info);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@*
|
||||||
|
Site Call Audit (#22) Task 7 — three Health-dashboard KPI tiles for the
|
||||||
|
Site Call channel: Buffered / Parked / Stuck. Renders Bootstrap card tiles
|
||||||
|
in a single row, each acting as a navigation link to a pre-filtered Site
|
||||||
|
Calls report view. The component is purely presentational — the parent page
|
||||||
|
owns the refresh loop and passes the latest snapshot via the Snapshot
|
||||||
|
parameter. Mirrors AuditKpiTiles and the Notification Outbox KPI section.
|
||||||
|
*@
|
||||||
|
|
||||||
|
@namespace ScadaLink.CentralUI.Components.Health
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="text-muted mb-0">Site Calls</h6>
|
||||||
|
<a class="small" href="/site-calls/report">View details →</a>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
@* ── Buffered tile ─────────────────────────────────────────────────────── *@
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<button type="button"
|
||||||
|
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile"
|
||||||
|
data-test="site-call-kpi-buffered"
|
||||||
|
@onclick="NavigateToBuffered">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h3 class="mb-0">@BufferedDisplay</h3>
|
||||||
|
<small class="text-muted">Buffered</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Stuck tile ────────────────────────────────────────────────────────── *@
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<button type="button"
|
||||||
|
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @StuckBorderClass"
|
||||||
|
data-test="site-call-kpi-stuck"
|
||||||
|
@onclick="NavigateToStuck">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h3 class="mb-0 @StuckTextClass">@StuckDisplay</h3>
|
||||||
|
<small class="text-muted">Stuck</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Parked tile ───────────────────────────────────────────────────────── *@
|
||||||
|
<div class="col-lg-4 col-md-6 col-12">
|
||||||
|
<button type="button"
|
||||||
|
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @ParkedBorderClass"
|
||||||
|
data-test="site-call-kpi-parked"
|
||||||
|
@onclick="NavigateToParked">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h3 class="mb-0 @ParkedTextClass">@ParkedDisplay</h3>
|
||||||
|
<small class="text-muted">Parked</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="text-muted small mb-3">Site Call KPIs unavailable: @ErrorMessage</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Call Audit (#22) Task 7 code-behind for <see cref="SiteCallKpiTiles"/>.
|
||||||
|
/// Renders three KPI tiles — Buffered, Stuck, Parked — from a
|
||||||
|
/// <see cref="SiteCallKpiResponse"/> the parent Health dashboard supplies.
|
||||||
|
/// Tiles act as drill-in links: clicking navigates to <c>/site-calls/report</c>
|
||||||
|
/// with the relevant query-string filter pre-applied. Mirrors
|
||||||
|
/// <see cref="AuditKpiTiles"/> and the Notification Outbox KPI section on the
|
||||||
|
/// Health dashboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why purely presentational.</b> The Health dashboard already owns a 10s
|
||||||
|
/// auto-refresh loop; pushing that into the tile component would either
|
||||||
|
/// duplicate it (one timer per tile) or awkwardly couple back to the page. The
|
||||||
|
/// parent passes a fresh <see cref="SiteCallKpiResponse"/> every refresh and the
|
||||||
|
/// tile component re-renders. This is the same contract <see cref="AuditKpiTiles"/>
|
||||||
|
/// follows.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Snapshot shape.</b> Unlike <see cref="AuditKpiTiles"/> — which takes a
|
||||||
|
/// dedicated <c>AuditLogKpiSnapshot</c> type — Site Call KPIs travel in the
|
||||||
|
/// <see cref="SiteCallKpiResponse"/> message itself (it carries the KPI fields
|
||||||
|
/// directly), so that record doubles as the snapshot here. <see cref="IsAvailable"/>
|
||||||
|
/// is a separate flag rather than the record's own <c>Success</c> so the parent
|
||||||
|
/// can also surface a transport failure (an Ask that threw) as unavailable.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Threshold borders.</b> Mirrors the Notification Outbox tile pattern: the
|
||||||
|
/// Parked tile gets a danger border when <c>ParkedCount > 0</c>; the Stuck
|
||||||
|
/// tile gets a warning border when <c>StuckCount > 0</c>. Buffered is a plain
|
||||||
|
/// count tile with no threshold colour — a non-zero buffer is normal operation.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public partial class SiteCallKpiTiles
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
|
||||||
|
/// or the load failed — the tiles render em dashes in that case.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public SiteCallKpiResponse? Snapshot { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <see cref="Snapshot"/> is a successful query result. False when
|
||||||
|
/// the parent's refresh threw, or the response itself reported a fault, and
|
||||||
|
/// the displayed values should be rendered as em dashes with an error
|
||||||
|
/// explanation underneath.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public bool IsAvailable { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional error message to render underneath the tiles when
|
||||||
|
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
|
||||||
|
/// section on the Health dashboard surfaces transient KPI failures.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
// ── Buffered tile ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string BufferedDisplay =>
|
||||||
|
IsAvailable && Snapshot is not null
|
||||||
|
? Snapshot.BufferedCount.ToString("N0")
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
private void NavigateToBuffered()
|
||||||
|
{
|
||||||
|
// Buffered is "everything still in flight" — no single status maps to
|
||||||
|
// it, so the natural drill-in is the unfiltered Site Calls report sorted
|
||||||
|
// by newest, mirroring how the Audit volume/backlog tiles drop the
|
||||||
|
// operator on the unfiltered Audit Log grid.
|
||||||
|
Navigation.NavigateTo("/site-calls/report");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stuck tile ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string StuckDisplay =>
|
||||||
|
IsAvailable && Snapshot is not null
|
||||||
|
? Snapshot.StuckCount.ToString("N0")
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
// Stuck above zero is a warning signal — cached calls that have been
|
||||||
|
// Pending/Retrying past the stuck-age threshold. Matches the Notification
|
||||||
|
// Outbox Stuck tile (border-warning when StuckCount > 0).
|
||||||
|
private string StuckBorderClass =>
|
||||||
|
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
|
||||||
|
? "border-warning"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
private string StuckTextClass =>
|
||||||
|
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
|
||||||
|
? "text-warning"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
private void NavigateToStuck()
|
||||||
|
{
|
||||||
|
// Drill in with the report's "stuck only" filter pre-applied.
|
||||||
|
Navigation.NavigateTo("/site-calls/report?stuck=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parked tile ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private string ParkedDisplay =>
|
||||||
|
IsAvailable && Snapshot is not null
|
||||||
|
? Snapshot.ParkedCount.ToString("N0")
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
// Parked above zero is a danger signal — cached calls that exhausted retries
|
||||||
|
// and need an operator Retry/Discard. Matches the Notification Outbox Parked
|
||||||
|
// tile (border-danger when ParkedCount > 0).
|
||||||
|
private string ParkedBorderClass =>
|
||||||
|
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
|
||||||
|
? "border-danger"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
private string ParkedTextClass =>
|
||||||
|
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
|
||||||
|
? "text-danger"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
private void NavigateToParked()
|
||||||
|
{
|
||||||
|
// Drill in pre-filtered to Parked — the report's Status filter accepts
|
||||||
|
// ?status=Parked and Parked rows carry the Retry/Discard relay actions.
|
||||||
|
Navigation.NavigateTo("/site-calls/report?status=Parked");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,19 @@
|
|||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
|
@* Site Calls — Site Call Audit (#22). Deployment-role only,
|
||||||
|
matching the Notification Report page's gate; the section
|
||||||
|
header sits inside the policy block so a non-Deployment
|
||||||
|
user does not see the heading. *@
|
||||||
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||||
|
<Authorized Context="siteCallsContext">
|
||||||
|
<div role="presentation" class="nav-section-header">Site Calls</div>
|
||||||
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
|
||||||
|
</li>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
||||||
Parked Messages are Deployment-role only (Component-CentralUI). *@
|
Parked Messages are Deployment-role only (Component-CentralUI). *@
|
||||||
<div role="presentation" class="nav-section-header">Monitoring</div>
|
<div role="presentation" class="nav-section-header">Monitoring</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
|
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
|
||||||
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
|
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
|
||||||
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, and the UI-only
|
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
|
||||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||||
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
|
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
|
||||||
@@ -80,33 +80,27 @@ public partial class AuditLogPage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string? site = null;
|
// site/channel/kind/status accept repeated params for symmetry with the
|
||||||
if (query.TryGetValue("site", out var siteValues))
|
// multi-value export URL — a single ?site=/?channel=/?kind=/?status=
|
||||||
{
|
// drill-in still works (one-element list). Unknown enum names are silently
|
||||||
var v = siteValues.ToString();
|
// dropped. The lax-parse contract is shared with the two export endpoints
|
||||||
if (!string.IsNullOrWhiteSpace(v))
|
// via AuditQueryParamParsers so all three surfaces stay in lockstep.
|
||||||
{
|
IReadOnlyList<string>? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site"));
|
||||||
site = v.Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AuditChannel? channel = null;
|
IReadOnlyList<AuditChannel>? channels =
|
||||||
if (query.TryGetValue("channel", out var channelValues)
|
AuditQueryParamParsers.ParseEnumList<AuditChannel>(Raw(query, "channel"));
|
||||||
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
|
|
||||||
{
|
// ?kind= is honored for symmetry with BuildExportUrl, which emits a kind=
|
||||||
channel = parsedChannel;
|
// param — a kind drill-in deep link must round-trip back into the filter.
|
||||||
}
|
IReadOnlyList<AuditKind>? kinds =
|
||||||
|
AuditQueryParamParsers.ParseEnumList<AuditKind>(Raw(query, "kind"));
|
||||||
|
|
||||||
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in
|
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in
|
||||||
// with ?status=Failed (and operators may craft URLs with Parked/Discarded).
|
// with ?status=Failed (and operators may craft URLs with Parked/Discarded).
|
||||||
// Unknown values are silently dropped — the page still renders without
|
// Unknown values are silently dropped — the page still renders without
|
||||||
// the constraint.
|
// the constraint.
|
||||||
AuditStatus? status = null;
|
IReadOnlyList<AuditStatus>? statuses =
|
||||||
if (query.TryGetValue("status", out var statusValues)
|
AuditQueryParamParsers.ParseEnumList<AuditStatus>(Raw(query, "status"));
|
||||||
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
|
|
||||||
{
|
|
||||||
status = parsedStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance is UI-only — the filter contract has no matching column, so we
|
// Instance is UI-only — the filter contract has no matching column, so we
|
||||||
// pass it as a separate seam to the filter bar.
|
// pass it as a separate seam to the filter bar.
|
||||||
@@ -123,20 +117,33 @@ public partial class AuditLogPage
|
|||||||
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
|
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
|
||||||
// because the filter contract has no instance column — the user still needs
|
// because the filter contract has no instance column — the user still needs
|
||||||
// to refine + Apply for those.
|
// to refine + Apply for those.
|
||||||
if (correlationId is null && target is null && actor is null && site is null && channel is null && status is null)
|
if (correlationId is null && target is null && actor is null
|
||||||
|
&& sites is null && channels is null && kinds is null && statuses is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentFilter = new AuditLogQueryFilter(
|
_currentFilter = new AuditLogQueryFilter(
|
||||||
Channel: channel,
|
Channels: channels,
|
||||||
Status: status,
|
Kinds: kinds,
|
||||||
SourceSiteId: site,
|
Statuses: statuses,
|
||||||
|
SourceSiteIds: sites,
|
||||||
Target: target,
|
Target: target,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
CorrelationId: correlationId);
|
CorrelationId: correlationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the raw repeated values for one query-string key, returning
|
||||||
|
/// <c>null</c> when the key is absent so the shared
|
||||||
|
/// <see cref="AuditQueryParamParsers"/> sees the same absent-vs-present
|
||||||
|
/// distinction the ASP.NET <c>IQueryCollection</c> callers do.
|
||||||
|
/// <c>StringValues</c> is itself an <c>IEnumerable<string?></c>.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<string?>? Raw(
|
||||||
|
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
|
||||||
|
query.TryGetValue(key, out var values) ? (IEnumerable<string?>)values : null;
|
||||||
|
|
||||||
private void HandleFilterChanged(AuditLogQueryFilter filter)
|
private void HandleFilterChanged(AuditLogQueryFilter filter)
|
||||||
{
|
{
|
||||||
// Always reassign — the grid keys reloads on reference change, so even a
|
// Always reassign — the grid keys reloads on reference change, so even a
|
||||||
@@ -180,22 +187,42 @@ public partial class AuditLogPage
|
|||||||
return basePath;
|
return basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parts = new List<KeyValuePair<string, string?>>(9);
|
// No capacity hint: the dimensions are multi-value, so the part count is
|
||||||
if (filter.Channel is { } ch)
|
// unbounded by the number of filter fields.
|
||||||
|
var parts = new List<KeyValuePair<string, string?>>();
|
||||||
|
// Task 9: the filter dimensions are multi-value end-to-end. Emit ONE
|
||||||
|
// repeated query-string key per selected value (channel=A&channel=B); the
|
||||||
|
// export endpoint's ParseFilter reads the full repeated set.
|
||||||
|
if (filter.Channels is { Count: > 0 } channels)
|
||||||
{
|
{
|
||||||
parts.Add(new("channel", ch.ToString()));
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
parts.Add(new("channel", channel.ToString()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (filter.Kind is { } kind)
|
if (filter.Kinds is { Count: > 0 } kinds)
|
||||||
{
|
{
|
||||||
parts.Add(new("kind", kind.ToString()));
|
foreach (var kind in kinds)
|
||||||
|
{
|
||||||
|
parts.Add(new("kind", kind.ToString()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (filter.Status is { } status)
|
if (filter.Statuses is { Count: > 0 } statuses)
|
||||||
{
|
{
|
||||||
parts.Add(new("status", status.ToString()));
|
foreach (var status in statuses)
|
||||||
|
{
|
||||||
|
parts.Add(new("status", status.ToString()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(filter.SourceSiteId))
|
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
|
||||||
{
|
{
|
||||||
parts.Add(new("site", filter.SourceSiteId));
|
foreach (var site in sourceSiteIds)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(site))
|
||||||
|
{
|
||||||
|
parts.Add(new("site", site));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Target))
|
if (!string.IsNullOrWhiteSpace(filter.Target))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@using ScadaLink.HealthMonitoring
|
@using ScadaLink.HealthMonitoring
|
||||||
@using ScadaLink.Commons.Messages.Notification
|
@using ScadaLink.Commons.Messages.Notification
|
||||||
|
@using ScadaLink.Commons.Messages.Audit
|
||||||
@using ScadaLink.Communication
|
@using ScadaLink.Communication
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject ICentralHealthAggregator HealthAggregator
|
@inject ICentralHealthAggregator HealthAggregator
|
||||||
@@ -60,6 +61,12 @@
|
|||||||
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
|
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel
|
||||||
|
(buffered / stuck / parked). Refreshed alongside the site states. *@
|
||||||
|
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
|
||||||
|
IsAvailable="@_siteCallKpiAvailable"
|
||||||
|
ErrorMessage="@_siteCallKpiError" />
|
||||||
|
|
||||||
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
|
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
|
||||||
(volume / error rate / backlog). Refreshed alongside the site states. *@
|
(volume / error rate / backlog). Refreshed alongside the site states. *@
|
||||||
<AuditKpiTiles Snapshot="@_auditKpi"
|
<AuditKpiTiles Snapshot="@_auditKpi"
|
||||||
@@ -364,6 +371,13 @@
|
|||||||
private bool _auditKpiAvailable;
|
private bool _auditKpiAvailable;
|
||||||
private string? _auditKpiError;
|
private string? _auditKpiError;
|
||||||
|
|
||||||
|
// Site Call Audit (#22) Task 7 — Site Call KPI tiles. Point-in-time counts
|
||||||
|
// from the central SiteCalls table, fetched alongside the site states. The
|
||||||
|
// SiteCallKpiResponse message doubles as the snapshot the tile takes.
|
||||||
|
private SiteCallKpiResponse? _siteCallKpi;
|
||||||
|
private bool _siteCallKpiAvailable;
|
||||||
|
private string? _siteCallKpiError;
|
||||||
|
|
||||||
private static bool SiteHasActiveErrors(SiteHealthState state)
|
private static bool SiteHasActiveErrors(SiteHealthState state)
|
||||||
{
|
{
|
||||||
var report = state.LatestReport;
|
var report = state.LatestReport;
|
||||||
@@ -401,6 +415,7 @@
|
|||||||
{
|
{
|
||||||
_siteStates = HealthAggregator.GetAllSiteStates();
|
_siteStates = HealthAggregator.GetAllSiteStates();
|
||||||
await LoadOutboxKpis();
|
await LoadOutboxKpis();
|
||||||
|
await LoadSiteCallKpis();
|
||||||
await LoadAuditKpis();
|
await LoadAuditKpis();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +444,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Site Call KPI loader: wraps the service call so a transient fault degrades
|
||||||
|
// the three Site Call tiles to em dashes with an inline error rather than
|
||||||
|
// killing the dashboard. Mirrors LoadOutboxKpis's error handling shape — a
|
||||||
|
// response with Success == false (repository fault) and an Ask that threw
|
||||||
|
// (transport fault) both collapse to "unavailable".
|
||||||
|
private async Task LoadSiteCallKpis()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await CommunicationService.GetSiteCallKpisAsync(
|
||||||
|
new SiteCallKpiRequest(Guid.NewGuid().ToString("N")));
|
||||||
|
if (response.Success)
|
||||||
|
{
|
||||||
|
_siteCallKpi = response;
|
||||||
|
_siteCallKpiAvailable = true;
|
||||||
|
_siteCallKpiError = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_siteCallKpiAvailable = false;
|
||||||
|
_siteCallKpiError = response.ErrorMessage ?? "KPI query failed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_siteCallKpiAvailable = false;
|
||||||
|
_siteCallKpiError = $"KPI query failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tiles show the numeric KPI when available, or an em dash when the outbox
|
// Tiles show the numeric KPI when available, or an em dash when the outbox
|
||||||
// KPI query failed — matching how the page renders other unavailable data.
|
// KPI query failed — matching how the page renders other unavailable data.
|
||||||
private string OutboxTileValue(int value) =>
|
private string OutboxTileValue(int value) =>
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
@page "/site-calls/report"
|
||||||
|
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
|
||||||
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@using ScadaLink.Commons.Messages.Audit
|
||||||
|
@using ScadaLink.Communication
|
||||||
|
@inject CommunicationService CommunicationService
|
||||||
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject IDialogService Dialog
|
||||||
|
@inject ILogger<SiteCallsReport> Logger
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">Site Calls</h4>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
|
||||||
|
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Filters ── *@
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label small mb-1" for="sc-status">Status</label>
|
||||||
|
<select id="sc-status" class="form-select form-select-sm" style="min-width: 130px;"
|
||||||
|
@bind="_statusFilter">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="Submitted">Submitted</option>
|
||||||
|
<option value="Forwarded">Forwarded</option>
|
||||||
|
<option value="Attempted">Attempted</option>
|
||||||
|
<option value="Delivered">Delivered</option>
|
||||||
|
<option value="Parked">Parked</option>
|
||||||
|
<option value="Failed">Failed</option>
|
||||||
|
<option value="Discarded">Discarded</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label small mb-1" for="sc-channel">Channel</label>
|
||||||
|
<select id="sc-channel" class="form-select form-select-sm" style="min-width: 130px;"
|
||||||
|
@bind="_channelFilter">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="ApiOutbound">ApiOutbound</option>
|
||||||
|
<option value="DbOutbound">DbOutbound</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label small mb-1" for="sc-site">Source site</label>
|
||||||
|
<select id="sc-site" class="form-select form-select-sm" style="min-width: 150px;"
|
||||||
|
@bind="_siteFilter">
|
||||||
|
<option value="">Any</option>
|
||||||
|
@foreach (var site in _sites)
|
||||||
|
{
|
||||||
|
<option value="@site.SiteIdentifier">@site.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label small mb-1" for="sc-from">From</label>
|
||||||
|
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
|
||||||
|
@bind="_fromFilter" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label small mb-1" for="sc-to">To</label>
|
||||||
|
<input id="sc-to" type="datetime-local" class="form-control form-control-sm"
|
||||||
|
@bind="_toFilter" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label small mb-1" for="sc-search">Target keyword</label>
|
||||||
|
<input id="sc-search" type="search" class="form-control form-control-sm"
|
||||||
|
placeholder="Exact target…" @bind="_targetFilter" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="form-check mb-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sc-stuck-only"
|
||||||
|
@bind="_stuckOnly" />
|
||||||
|
<label class="form-check-label small" for="sc-stuck-only">Stuck only</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
|
||||||
|
disabled="@(!HasActiveFilters)">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_loading"
|
||||||
|
data-test="site-calls-query">
|
||||||
|
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||||
|
Query
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_listError != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_listError</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Site call list ── *@
|
||||||
|
@if (_siteCalls == null)
|
||||||
|
{
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<div class="text-muted small">Loading…</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_siteCalls.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center text-muted py-5">
|
||||||
|
<div class="fs-5 mb-1">No site calls</div>
|
||||||
|
<div class="small">No cached calls match the current filters.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Tracked operation</th>
|
||||||
|
<th>Source site</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Retries</th>
|
||||||
|
<th>Last error</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _siteCalls)
|
||||||
|
{
|
||||||
|
<tr @key="c.TrackedOperationId" class="@(c.IsStuck ? "table-warning" : "")"
|
||||||
|
style="cursor: pointer;" @ondblclick="() => ShowDetail(c)"
|
||||||
|
title="Double-click for full detail">
|
||||||
|
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
|
||||||
|
<td><span class="small">@SiteName(c.SourceSite)</span></td>
|
||||||
|
<td>@c.Channel</td>
|
||||||
|
<td>@c.Target</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge @StatusBadgeClass(c.Status)">@c.Status</span>
|
||||||
|
@if (c.IsStuck)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark ms-1">Stuck</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-end font-monospace">@c.RetryCount</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(c.LastError))
|
||||||
|
{
|
||||||
|
<div class="small text-danger text-truncate" style="max-width: 280px;"
|
||||||
|
title="@c.LastError">@c.LastError</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td><TimestampDisplay Value="@AsOffset(c.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
|
||||||
|
<td><TimestampDisplay Value="@AsOffset(c.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm" /></td>
|
||||||
|
<td class="text-end" @ondblclick:stopPropagation="true">
|
||||||
|
@* The TrackedOperationId is the audit CorrelationId, so the
|
||||||
|
link deep-links into the central Audit Log pre-filtered to
|
||||||
|
this cached call's lifecycle events. *@
|
||||||
|
<a class="btn btn-outline-secondary btn-sm me-1"
|
||||||
|
href="/audit/log?correlationId=@c.TrackedOperationId"
|
||||||
|
data-test="audit-link-@c.TrackedOperationId">
|
||||||
|
View audit history
|
||||||
|
</a>
|
||||||
|
@* Retry/Discard relay only on Parked rows — central relays the
|
||||||
|
action to the owning site; Failed and other statuses are not
|
||||||
|
actionable from central. *@
|
||||||
|
@if (c.Status == "Parked")
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-success btn-sm me-1"
|
||||||
|
@onclick="() => RetrySiteCall(c)" disabled="@_actionInProgress">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm"
|
||||||
|
@onclick="() => DiscardSiteCall(c)" disabled="@_actionInProgress">
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Keyset paging — the Task 4 query response carries a (CreatedAtUtc, Id)
|
||||||
|
cursor rather than page numbers, so we keep a stack of cursors to step
|
||||||
|
backwards and the response's NextAfter* cursor to step forwards. *@
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">
|
||||||
|
@* No "of N" total: keyset paging has no cheap total-count, so
|
||||||
|
the label is intentionally page-number-only. Do not "fix"
|
||||||
|
this by adding a total — that would require a COUNT(*). *@
|
||||||
|
Page @(_cursorStack.Count + 1) · @_siteCalls.Count rows
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm me-1"
|
||||||
|
@onclick="PrevPage" disabled="@(_cursorStack.Count == 0 || _loading)"
|
||||||
|
data-test="site-calls-prev">Previous</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
|
@onclick="NextPage" disabled="@(!HasNextPage || _loading)"
|
||||||
|
data-test="site-calls-next">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Row detail modal ── *@
|
||||||
|
@if (_detailSiteCall != null)
|
||||||
|
{
|
||||||
|
var d = _detailSiteCall;
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"
|
||||||
|
@onclick="CloseDetail">
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title">Site Call Detail — @ShortId(d.TrackedOperationId)</h6>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close"
|
||||||
|
@onclick="CloseDetail"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (_detailLoading)
|
||||||
|
{
|
||||||
|
<div class="text-muted small">
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
Loading details…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_detailError != null)
|
||||||
|
{
|
||||||
|
<div class="text-danger small">@_detailError</div>
|
||||||
|
}
|
||||||
|
else if (_detail != null)
|
||||||
|
{
|
||||||
|
var det = _detail;
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-3">Tracked operation</dt>
|
||||||
|
<dd class="col-sm-9"><code>@det.TrackedOperationId</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Source site</dt>
|
||||||
|
<dd class="col-sm-9">@SiteName(det.SourceSite)</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Channel</dt>
|
||||||
|
<dd class="col-sm-9">@det.Channel</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Target</dt>
|
||||||
|
<dd class="col-sm-9">@det.Target</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Status</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<span class="badge @StatusBadgeClass(det.Status)">@det.Status</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Retry count</dt>
|
||||||
|
<dd class="col-sm-9 font-monospace">@det.RetryCount</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">HTTP status</dt>
|
||||||
|
<dd class="col-sm-9">@(det.HttpStatus?.ToString() ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Created</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<TimestampDisplay Value="@AsOffset(det.CreatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Updated</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<TimestampDisplay Value="@AsOffset(det.UpdatedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Terminal</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<TimestampDisplay Value="@AsOffset(det.TerminalAtUtc)"
|
||||||
|
Format="yyyy-MM-dd HH:mm:ss" NullText="—" />
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Ingested (central)</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<TimestampDisplay Value="@AsOffset(det.IngestedAtUtc)" Format="yyyy-MM-dd HH:mm:ss" />
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(det.LastError))
|
||||||
|
{
|
||||||
|
<dt class="col-sm-3">Last error</dt>
|
||||||
|
@* Plain text — never a MarkupString. *@
|
||||||
|
<dd class="col-sm-9 text-danger">@det.LastError</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
@if (d.Status == "Parked")
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-success btn-sm"
|
||||||
|
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm"
|
||||||
|
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.CentralUI.Components.Shared;
|
||||||
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Pages.SiteCalls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Code-behind for the central Site Calls report page (Site Call Audit #22). A
|
||||||
|
/// near-mirror of <see cref="ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport"/>:
|
||||||
|
/// it queries the central <c>SiteCalls</c> table via
|
||||||
|
/// <see cref="ScadaLink.Communication.CommunicationService.QuerySiteCallsAsync"/>,
|
||||||
|
/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard
|
||||||
|
/// of <c>Parked</c> cached calls to their owning site.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Unlike the Notification report, the query response uses a <c>(CreatedAtUtc DESC,
|
||||||
|
/// TrackedOperationId DESC)</c> keyset cursor rather than page numbers, so paging
|
||||||
|
/// keeps a stack of the cursors that opened each page (to step backwards) plus the
|
||||||
|
/// response's <c>NextAfter*</c> cursor (to step forwards).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retry/Discard relay to the owning site has a distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/>
|
||||||
|
/// outcome — central is an eventually-consistent mirror, not the source of truth, so
|
||||||
|
/// a relay that never reaches the site is a transient transport condition, surfaced
|
||||||
|
/// to the operator differently from a generic failure.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
|
||||||
|
/// with <c>?status=Parked</c> (Parked tile) or <c>?stuck=true</c> (Stuck tile). On
|
||||||
|
/// initialization those params seed <see cref="_statusFilter"/> / <see cref="_stuckOnly"/>
|
||||||
|
/// BEFORE the first <see cref="RefreshAll"/>, so the first grid load is already
|
||||||
|
/// filtered and the filter card controls reflect the seeded values. Parsing is lax
|
||||||
|
/// — an absent, blank, or unrecognised value is silently dropped and the page loads
|
||||||
|
/// unfiltered, mirroring <c>AuditLogPage</c>'s drill-in convention.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class SiteCallsReport
|
||||||
|
{
|
||||||
|
private const int PageSize = 50;
|
||||||
|
|
||||||
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
|
|
||||||
|
// The Status filter <select> options — the exact strings the dropdown binds and
|
||||||
|
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
|
||||||
|
// filter when it matches one of these (case-insensitively); anything else is
|
||||||
|
// dropped so a hand-crafted bad URL still renders the page unfiltered.
|
||||||
|
private static readonly string[] ValidStatuses =
|
||||||
|
{
|
||||||
|
"Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
|
||||||
|
};
|
||||||
|
|
||||||
|
private ToastNotification _toast = default!;
|
||||||
|
private List<Site> _sites = new();
|
||||||
|
|
||||||
|
// List
|
||||||
|
private List<SiteCallSummary>? _siteCalls;
|
||||||
|
private bool _loading;
|
||||||
|
private string? _listError;
|
||||||
|
private bool _actionInProgress;
|
||||||
|
|
||||||
|
// Keyset paging. The first page is opened with the empty (null, null) cursor.
|
||||||
|
// _cursorStack holds the cursors of the PREVIOUSLY visited pages — it is empty
|
||||||
|
// on page 1, has one entry on page 2, and so on; Previous pops it. _nextCursor
|
||||||
|
// is the cursor for the following page, echoed back by the last query.
|
||||||
|
private readonly Stack<(DateTime? AfterCreatedAtUtc, Guid? AfterId)> _cursorStack = new();
|
||||||
|
private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null);
|
||||||
|
private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor;
|
||||||
|
|
||||||
|
// Row detail modal
|
||||||
|
private SiteCallSummary? _detailSiteCall;
|
||||||
|
private SiteCallDetail? _detail;
|
||||||
|
private bool _detailLoading;
|
||||||
|
private string? _detailError;
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
private string _statusFilter = string.Empty;
|
||||||
|
private string _channelFilter = string.Empty;
|
||||||
|
private string _siteFilter = string.Empty;
|
||||||
|
private string _targetFilter = string.Empty;
|
||||||
|
private bool _stuckOnly;
|
||||||
|
private DateTime? _fromFilter;
|
||||||
|
private DateTime? _toFilter;
|
||||||
|
|
||||||
|
private bool HasNextPage => _nextCursor is not null;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Non-fatal — the source-site filter just falls back to raw site IDs.
|
||||||
|
Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
|
||||||
|
// grid load is already filtered (and the filter card controls reflect it).
|
||||||
|
ApplyQueryStringFilters();
|
||||||
|
|
||||||
|
await RefreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
|
||||||
|
/// string. <c>?status=<status></c> seeds <see cref="_statusFilter"/> when it
|
||||||
|
/// matches a known status (case-insensitive); <c>?stuck=true</c> seeds
|
||||||
|
/// <see cref="_stuckOnly"/>. Lax parsing — an absent, blank, or unrecognised value
|
||||||
|
/// is silently dropped, leaving the filter empty (the no-param behaviour).
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyQueryStringFilters()
|
||||||
|
{
|
||||||
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
|
||||||
|
if (query.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.TryGetValue("status", out var statusValues))
|
||||||
|
{
|
||||||
|
var v = statusValues.ToString();
|
||||||
|
// Round-trip the dropdown's own option strings (the KPI tile emits the
|
||||||
|
// canonical casing, e.g. ?status=Parked); normalise to that casing so the
|
||||||
|
// <select> binds. An unrecognised value leaves the filter unset.
|
||||||
|
var match = ValidStatuses.FirstOrDefault(
|
||||||
|
s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
_statusFilter = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.TryGetValue("stuck", out var stuckValues)
|
||||||
|
&& bool.TryParse(stuckValues.ToString(), out var stuck))
|
||||||
|
{
|
||||||
|
_stuckOnly = stuck;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
|
||||||
|
private async Task RefreshAll()
|
||||||
|
{
|
||||||
|
await FetchPage(_currentCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Apply the filters and start again from the first page.</summary>
|
||||||
|
private async Task Search()
|
||||||
|
{
|
||||||
|
_cursorStack.Clear();
|
||||||
|
await FetchPage((null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrevPage()
|
||||||
|
{
|
||||||
|
if (_cursorStack.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The top of the stack is the cursor of the page BEFORE the current one.
|
||||||
|
var previousCursor = _cursorStack.Pop();
|
||||||
|
await FetchPage(previousCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NextPage()
|
||||||
|
{
|
||||||
|
if (_nextCursor is not { } next)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stepping forward: remember the current page's cursor so Previous can
|
||||||
|
// return to it.
|
||||||
|
_cursorStack.Push(_currentCursor);
|
||||||
|
await FetchPage(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch one keyset page starting after <paramref name="cursor"/>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task FetchPage(
|
||||||
|
(DateTime? AfterCreatedAtUtc, Guid? AfterId) cursor)
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_listError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new SiteCallQueryRequest(
|
||||||
|
CorrelationId: Guid.NewGuid().ToString("N"),
|
||||||
|
StatusFilter: NullIfEmpty(_statusFilter),
|
||||||
|
SourceSiteFilter: NullIfEmpty(_siteFilter),
|
||||||
|
ChannelFilter: NullIfEmpty(_channelFilter),
|
||||||
|
TargetKeyword: NullIfEmpty(_targetFilter),
|
||||||
|
StuckOnly: _stuckOnly,
|
||||||
|
FromUtc: ToUtc(_fromFilter),
|
||||||
|
ToUtc: ToUtc(_toFilter),
|
||||||
|
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
|
||||||
|
AfterId: cursor.AfterId,
|
||||||
|
PageSize: PageSize);
|
||||||
|
|
||||||
|
var response = await CommunicationService.QuerySiteCallsAsync(request);
|
||||||
|
if (response.Success)
|
||||||
|
{
|
||||||
|
_siteCalls = response.SiteCalls.ToList();
|
||||||
|
_currentCursor = cursor;
|
||||||
|
|
||||||
|
// The response echoes the last row's cursor. A short page (fewer
|
||||||
|
// rows than requested) has no further page even if a cursor came
|
||||||
|
// back, so gate Next on a full page too.
|
||||||
|
_nextCursor = response.NextAfterCreatedAtUtc is { } nextCreated
|
||||||
|
&& response.NextAfterId is { } nextId
|
||||||
|
&& _siteCalls.Count == PageSize
|
||||||
|
? (nextCreated, nextId)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_listError = response.ErrorMessage ?? "Query failed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_listError = $"Query failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RetrySiteCall(SiteCallSummary c)
|
||||||
|
{
|
||||||
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
|
"Retry cached call",
|
||||||
|
$"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||||
|
$"to site {SiteName(c.SourceSite)}?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
_actionInProgress = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await CommunicationService.RetrySiteCallAsync(
|
||||||
|
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
|
||||||
|
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
|
||||||
|
appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
|
||||||
|
if (response.Success)
|
||||||
|
{
|
||||||
|
await RefreshAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Retry failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
_actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DiscardSiteCall(SiteCallSummary c)
|
||||||
|
{
|
||||||
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
|
"Discard cached call",
|
||||||
|
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||||
|
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
|
||||||
|
danger: true);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
_actionInProgress = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await CommunicationService.DiscardSiteCallAsync(
|
||||||
|
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite));
|
||||||
|
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
|
||||||
|
appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
|
||||||
|
if (response.Success)
|
||||||
|
{
|
||||||
|
await RefreshAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_toast.ShowError($"Discard failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
_actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Surface a relay outcome on the toast — exactly one toast per relay
|
||||||
|
/// response. The <see cref="SiteCallRelayOutcome.SiteUnreachable"/> case is
|
||||||
|
/// deliberately distinct from a generic failure: the action was not applied
|
||||||
|
/// but the operator can retry once the site is back online.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <see cref="SiteCallRelayOutcome"/> switch is exhaustive, so it owns
|
||||||
|
/// the single toast. <paramref name="siteReachable"/> is a redundant
|
||||||
|
/// cross-check on the same signal (the contract sets it <c>false</c> only
|
||||||
|
/// for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>); it is folded
|
||||||
|
/// INTO the <see cref="SiteCallRelayOutcome.OperationFailed"/> case rather
|
||||||
|
/// than firing a second toast — an <c>OperationFailed</c> response that also
|
||||||
|
/// reports an unreachable site shows the unreachable wording, once.
|
||||||
|
/// </remarks>
|
||||||
|
private void ShowRelayOutcome(
|
||||||
|
SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage)
|
||||||
|
{
|
||||||
|
switch (outcome)
|
||||||
|
{
|
||||||
|
case SiteCallRelayOutcome.Applied:
|
||||||
|
_toast.ShowSuccess(appliedMessage);
|
||||||
|
break;
|
||||||
|
case SiteCallRelayOutcome.NotParked:
|
||||||
|
_toast.ShowInfo(errorMessage
|
||||||
|
?? "The site reported nothing to do — the cached call is no longer parked.");
|
||||||
|
break;
|
||||||
|
case SiteCallRelayOutcome.SiteUnreachable:
|
||||||
|
_toast.ShowError(errorMessage
|
||||||
|
?? "Site unreachable — the relay did not reach the owning site. "
|
||||||
|
+ "Try again once the site is back online.");
|
||||||
|
break;
|
||||||
|
case SiteCallRelayOutcome.OperationFailed when !siteReachable:
|
||||||
|
// An OperationFailed response that nonetheless reports the site
|
||||||
|
// unreachable: trust the reachability signal and show the
|
||||||
|
// unreachable wording instead of the generic failure message.
|
||||||
|
_toast.ShowError(errorMessage
|
||||||
|
?? "Site unreachable — the relay did not reach the owning site. "
|
||||||
|
+ "Try again once the site is back online.");
|
||||||
|
break;
|
||||||
|
case SiteCallRelayOutcome.OperationFailed:
|
||||||
|
default:
|
||||||
|
_toast.ShowError(errorMessage ?? "The site could not apply the action.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowDetail(SiteCallSummary c)
|
||||||
|
{
|
||||||
|
// The summary fields render immediately from the grid row; the full detail
|
||||||
|
// (HttpStatus, all timestamps, LastError) fills in once the fetch completes.
|
||||||
|
_detailSiteCall = c;
|
||||||
|
_detail = null;
|
||||||
|
_detailError = null;
|
||||||
|
_detailLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await CommunicationService.GetSiteCallDetailAsync(
|
||||||
|
new SiteCallDetailRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId));
|
||||||
|
if (response.Success && response.Detail != null)
|
||||||
|
{
|
||||||
|
_detail = response.Detail;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_detailError = response.ErrorMessage ?? "Failed to load site call detail.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_detailError = $"Failed to load site call detail: {ex.Message}";
|
||||||
|
}
|
||||||
|
_detailLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDetail()
|
||||||
|
{
|
||||||
|
_detailSiteCall = null;
|
||||||
|
_detail = null;
|
||||||
|
_detailError = null;
|
||||||
|
_detailLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RetryFromDetail(SiteCallSummary c)
|
||||||
|
{
|
||||||
|
await RetrySiteCall(c);
|
||||||
|
// RefreshAll replaces the row list; close the modal so the user sees the
|
||||||
|
// refreshed grid rather than a now-stale detail snapshot.
|
||||||
|
CloseDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DiscardFromDetail(SiteCallSummary c)
|
||||||
|
{
|
||||||
|
await DiscardSiteCall(c);
|
||||||
|
CloseDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearFilters()
|
||||||
|
{
|
||||||
|
_statusFilter = string.Empty;
|
||||||
|
_channelFilter = string.Empty;
|
||||||
|
_siteFilter = string.Empty;
|
||||||
|
_targetFilter = string.Empty;
|
||||||
|
_stuckOnly = false;
|
||||||
|
_fromFilter = null;
|
||||||
|
_toFilter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasActiveFilters =>
|
||||||
|
!string.IsNullOrEmpty(_statusFilter) ||
|
||||||
|
!string.IsNullOrEmpty(_channelFilter) ||
|
||||||
|
!string.IsNullOrEmpty(_siteFilter) ||
|
||||||
|
!string.IsNullOrEmpty(_targetFilter) ||
|
||||||
|
_stuckOnly ||
|
||||||
|
_fromFilter != null ||
|
||||||
|
_toFilter != null;
|
||||||
|
|
||||||
|
private string SiteName(string siteId) =>
|
||||||
|
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
|
||||||
|
|
||||||
|
private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The filter inputs are UTC wall-clock — stamp <see cref="DateTimeKind.Utc"/>
|
||||||
|
/// on the local-typed value so the query is unambiguous.
|
||||||
|
/// </summary>
|
||||||
|
private static DateTime? ToUtc(DateTime? value) =>
|
||||||
|
value == null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>SiteCalls</c> timestamps are UTC <see cref="DateTime"/>; wrap them as
|
||||||
|
/// a <see cref="DateTimeOffset"/> for <c>TimestampDisplay</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static DateTimeOffset? AsOffset(DateTime? value) =>
|
||||||
|
value == null
|
||||||
|
? null
|
||||||
|
: new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
// A Guid's "N" format is always exactly 32 hex chars, so the [..12] slice is
|
||||||
|
// always in range — no length guard needed.
|
||||||
|
private static string ShortId(Guid id) => id.ToString("N")[..12];
|
||||||
|
|
||||||
|
private static string StatusBadgeClass(string status) => status switch
|
||||||
|
{
|
||||||
|
"Delivered" => "bg-success",
|
||||||
|
"Parked" => "bg-danger",
|
||||||
|
"Failed" => "bg-danger",
|
||||||
|
"Attempted" => "bg-warning text-dark",
|
||||||
|
"Forwarded" => "bg-info text-dark",
|
||||||
|
"Submitted" => "bg-info text-dark",
|
||||||
|
"Discarded" => "bg-secondary",
|
||||||
|
_ => "bg-light text-dark"
|
||||||
|
};
|
||||||
|
}
|
||||||
190
src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js
Normal file
190
src/ScadaLink.CentralUI/wwwroot/js/audit-grid.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// Audit results grid column UX (#23 follow-ups Task 10).
|
||||||
|
//
|
||||||
|
// A tiny, dependency-free helper for the AuditResultsGrid component:
|
||||||
|
// - drag-to-resize: a pointer-driven handle on each <th>'s right edge,
|
||||||
|
// - drag-to-reorder: native HTML5 drag-and-drop on the header row,
|
||||||
|
// - save/load: a sessionStorage round-trip, mirroring treeview-storage.js.
|
||||||
|
//
|
||||||
|
// The Blazor component owns the column model; this file is purely the
|
||||||
|
// browser-side drag plumbing. After a resize or reorder it calls back into
|
||||||
|
// .NET via a DotNetObjectReference so the component can persist + re-render.
|
||||||
|
//
|
||||||
|
// No drag-drop libraries — hand-rolled pointer + native-DnD handlers only.
|
||||||
|
window.auditGrid = {
|
||||||
|
// --- sessionStorage wrapper (mirrors window.treeviewStorage) -----------
|
||||||
|
// Keys are namespaced under "auditGrid:" so they never collide with the
|
||||||
|
// treeview's "treeview:" namespace.
|
||||||
|
save: function (key, json) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("auditGrid:" + key, json);
|
||||||
|
} catch {
|
||||||
|
// Quota / privacy-mode failures are non-fatal — the grid simply
|
||||||
|
// falls back to defaults on the next load.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
load: function (key) {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem("auditGrid:" + key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Minimum column width in pixels. A column can never be dragged narrower
|
||||||
|
// than this so a header can't collapse to an unclickable sliver.
|
||||||
|
minWidth: 64,
|
||||||
|
|
||||||
|
// --- wire-up ----------------------------------------------------------
|
||||||
|
// `table` is the <table> element, `dotNet` is a DotNetObjectReference
|
||||||
|
// exposing OnColumnResized / OnColumnReordered. Safe to call on every
|
||||||
|
// render: it re-scans the header and binds only cells not already bound,
|
||||||
|
// and always refreshes the live .NET reference. Handlers read the column
|
||||||
|
// key live from data-col-key at event time, so Blazor reusing a <th> DOM
|
||||||
|
// node for a different column (after a reorder re-render) is harmless.
|
||||||
|
init: function (table, dotNet) {
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
table.__auditGridDotNet = dotNet;
|
||||||
|
|
||||||
|
var headerRow = table.tHead && table.tHead.rows[0];
|
||||||
|
if (!headerRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < headerRow.cells.length; i++) {
|
||||||
|
this._bindHeaderCell(table, headerRow.cells[i]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bind resize + reorder handlers to a single <th>. Idempotent — a cell
|
||||||
|
// already carrying handlers is skipped. The handlers resolve the column
|
||||||
|
// key live (th.getAttribute) so they stay correct if the renderer reuses
|
||||||
|
// the element for another column.
|
||||||
|
_bindHeaderCell: function (table, th) {
|
||||||
|
var self = this;
|
||||||
|
if (th.__auditGridCellBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
th.__auditGridCellBound = true;
|
||||||
|
|
||||||
|
// --- resize: pointer drag on the handle ---------------------------
|
||||||
|
var handle = th.querySelector(".audit-grid-resize-handle");
|
||||||
|
if (handle) {
|
||||||
|
handle.addEventListener("pointerdown", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
// Stop the pointerdown from also starting a header drag.
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
var startX = ev.clientX;
|
||||||
|
var startWidth = th.getBoundingClientRect().width;
|
||||||
|
handle.setPointerCapture(ev.pointerId);
|
||||||
|
th.classList.add("resizing");
|
||||||
|
|
||||||
|
function onMove(moveEv) {
|
||||||
|
var next = Math.max(self.minWidth, startWidth + (moveEv.clientX - startX));
|
||||||
|
self._applyWidth(th, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
handle.releasePointerCapture(ev.pointerId);
|
||||||
|
handle.removeEventListener("pointermove", onMove);
|
||||||
|
handle.removeEventListener("pointerup", onUp);
|
||||||
|
handle.removeEventListener("pointercancel", onUp);
|
||||||
|
th.classList.remove("resizing");
|
||||||
|
|
||||||
|
var key = th.getAttribute("data-col-key");
|
||||||
|
var finalWidth = Math.round(th.getBoundingClientRect().width);
|
||||||
|
var dn = table.__auditGridDotNet;
|
||||||
|
if (key && dn) {
|
||||||
|
dn.invokeMethodAsync("OnColumnResized", key, finalWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.addEventListener("pointermove", onMove);
|
||||||
|
handle.addEventListener("pointerup", onUp);
|
||||||
|
handle.addEventListener("pointercancel", onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reorder: native HTML5 drag-and-drop on the header ------------
|
||||||
|
// The whole <th> is draggable; dropping it onto another header swaps
|
||||||
|
// the dragged column into the drop target's position.
|
||||||
|
th.setAttribute("draggable", "true");
|
||||||
|
|
||||||
|
th.addEventListener("dragstart", function (ev) {
|
||||||
|
// A resize in progress sets .resizing; never start a reorder then.
|
||||||
|
if (th.classList.contains("resizing")) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var key = th.getAttribute("data-col-key");
|
||||||
|
if (!key) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
table.__auditGridDragKey = key;
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
// Some browsers require data to be set for the drag to begin.
|
||||||
|
try { ev.dataTransfer.setData("text/plain", key); } catch { /* ignore */ }
|
||||||
|
th.classList.add("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
th.addEventListener("dragend", function () {
|
||||||
|
th.classList.remove("dragging");
|
||||||
|
table.__auditGridDragKey = null;
|
||||||
|
self._clearDropTargets(table);
|
||||||
|
});
|
||||||
|
|
||||||
|
th.addEventListener("dragover", function (ev) {
|
||||||
|
// Allowing the drop is what lets dragover/drop fire at all.
|
||||||
|
var key = th.getAttribute("data-col-key");
|
||||||
|
if (key && table.__auditGridDragKey && table.__auditGridDragKey !== key) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = "move";
|
||||||
|
th.classList.add("drop-target");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
th.addEventListener("dragleave", function () {
|
||||||
|
th.classList.remove("drop-target");
|
||||||
|
});
|
||||||
|
|
||||||
|
th.addEventListener("drop", function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
th.classList.remove("drop-target");
|
||||||
|
var key = th.getAttribute("data-col-key");
|
||||||
|
var fromKey = table.__auditGridDragKey;
|
||||||
|
table.__auditGridDragKey = null;
|
||||||
|
if (!key || !fromKey || fromKey === key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dn = table.__auditGridDotNet;
|
||||||
|
if (dn) {
|
||||||
|
// fromKey moves to occupy toKey's slot; the component computes
|
||||||
|
// the resulting order and re-renders + persists.
|
||||||
|
dn.invokeMethodAsync("OnColumnReordered", fromKey, key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Apply a width to a <th> via a CSS custom property. The scoped stylesheet
|
||||||
|
// reads --audit-col-width; absent it, the column falls back to auto.
|
||||||
|
//
|
||||||
|
// Known, intentional behaviour: during a live resize drag this updates the
|
||||||
|
// <th> width immediately, but the <td> body cells only catch up on the next
|
||||||
|
// .NET re-render (driven by OnColumnResized at pointer-up). The brief
|
||||||
|
// header/body width mismatch mid-drag is an accepted trade-off for an
|
||||||
|
// internal tool — not a bug.
|
||||||
|
_applyWidth: function (th, widthPx) {
|
||||||
|
th.style.setProperty("--audit-col-width", widthPx + "px");
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearDropTargets: function (table) {
|
||||||
|
var hits = table.querySelectorAll(".drop-target, .dragging");
|
||||||
|
for (var i = 0; i < hits.length; i++) {
|
||||||
|
hits[i].classList.remove("drop-target", "dragging");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -63,4 +63,27 @@ public interface ISiteCallAuditRepository
|
|||||||
/// deleted.
|
/// deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default);
|
Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a point-in-time global <see cref="SiteCallKpiSnapshot"/> from the
|
||||||
|
/// <c>SiteCalls</c> table. Counts are aggregated server-side (no row
|
||||||
|
/// materialisation): <c>StuckCount</c> uses <paramref name="stuckCutoff"/>;
|
||||||
|
/// <c>FailedLastInterval</c> / <c>DeliveredLastInterval</c> use
|
||||||
|
/// <paramref name="intervalSince"/>; the current time for <c>OldestPendingAge</c>
|
||||||
|
/// is captured inside the method.
|
||||||
|
/// </summary>
|
||||||
|
Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTime stuckCutoff,
|
||||||
|
DateTime intervalSince,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a point-in-time <see cref="SiteCallSiteKpiSnapshot"/> per source
|
||||||
|
/// site. Sites with no <c>SiteCalls</c> rows at all are omitted. The stuck
|
||||||
|
/// cutoff and interval bounds are interpreted as in <see cref="ComputeKpisAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
|
DateTime stuckCutoff,
|
||||||
|
DateTime intervalSince,
|
||||||
|
CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
163
src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs
Normal file
163
src/ScadaLink.Commons/Messages/Audit/SiteCallQueries.cs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Messages.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Calls UI -> Central: paginated, filtered query over the central
|
||||||
|
/// <c>SiteCalls</c> table (Site Call Audit #22). All filter fields are optional;
|
||||||
|
/// <see cref="StuckOnly"/> restricts results to stuck cached calls. Mirrors
|
||||||
|
/// <see cref="ScadaLink.Commons.Messages.Notification.NotificationOutboxQueryRequest"/>
|
||||||
|
/// but uses keyset paging (<see cref="AfterCreatedAtUtc"/> + <see cref="AfterId"/>)
|
||||||
|
/// to match the repository's <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c>
|
||||||
|
/// cursor, rather than page numbers.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="ChannelFilter"/> matches the <c>SiteCall.Channel</c> column —
|
||||||
|
/// <c>"ApiOutbound"</c> or <c>"DbOutbound"</c> (the spec's <c>Kind</c> notion;
|
||||||
|
/// the entity exposes it as <c>Channel</c>). <see cref="TargetKeyword"/> is an
|
||||||
|
/// exact-match target filter, consistent with the repository's
|
||||||
|
/// <see cref="SiteCallQueryFilter.Target"/> predicate.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="PageSize">
|
||||||
|
/// Requested page size. The actor clamps this to the <c>[1, 200]</c> range, so
|
||||||
|
/// the effective ceiling is 200 rows per page regardless of the value sent.
|
||||||
|
/// </param>
|
||||||
|
public sealed record SiteCallQueryRequest(
|
||||||
|
string CorrelationId,
|
||||||
|
string? StatusFilter,
|
||||||
|
string? SourceSiteFilter,
|
||||||
|
string? ChannelFilter,
|
||||||
|
string? TargetKeyword,
|
||||||
|
bool StuckOnly,
|
||||||
|
DateTime? FromUtc,
|
||||||
|
DateTime? ToUtc,
|
||||||
|
DateTime? AfterCreatedAtUtc,
|
||||||
|
Guid? AfterId,
|
||||||
|
int PageSize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
|
||||||
|
/// only the columns the <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/>
|
||||||
|
/// entity genuinely exposes — there are no source-instance/script provenance
|
||||||
|
/// columns on that entity, so unlike
|
||||||
|
/// <see cref="ScadaLink.Commons.Messages.Notification.NotificationSummary"/>
|
||||||
|
/// none are surfaced here.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="HttpStatus"/> is not called out in the Site Call Audit plan, but
|
||||||
|
/// it is a real (nullable) <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/>
|
||||||
|
/// column — the last HTTP status code observed for the call — so it is surfaced
|
||||||
|
/// here for the grid; <c>null</c> for non-HTTP channels or before a first attempt.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SiteCallSummary(
|
||||||
|
Guid TrackedOperationId,
|
||||||
|
string SourceSite,
|
||||||
|
string Channel,
|
||||||
|
string Target,
|
||||||
|
string Status,
|
||||||
|
int RetryCount,
|
||||||
|
string? LastError,
|
||||||
|
int? HttpStatus,
|
||||||
|
DateTime CreatedAtUtc,
|
||||||
|
DateTime UpdatedAtUtc,
|
||||||
|
DateTime? TerminalAtUtc,
|
||||||
|
bool IsStuck);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
|
||||||
|
/// The keyset cursor of the last row is echoed back as
|
||||||
|
/// <see cref="NextAfterCreatedAtUtc"/> + <see cref="NextAfterId"/> for the caller
|
||||||
|
/// to request the following page; both are <c>null</c> when the page was empty.
|
||||||
|
/// On a repository fault <see cref="Success"/> is <c>false</c>,
|
||||||
|
/// <see cref="ErrorMessage"/> carries the cause and <see cref="SiteCalls"/> is empty.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SiteCallQueryResponse(
|
||||||
|
string CorrelationId,
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
IReadOnlyList<SiteCallSummary> SiteCalls,
|
||||||
|
DateTime? NextAfterCreatedAtUtc,
|
||||||
|
Guid? NextAfterId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Calls UI -> Central: request for the full detail of a single cached call,
|
||||||
|
/// for the report detail modal.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SiteCallDetailRequest(
|
||||||
|
string CorrelationId,
|
||||||
|
Guid TrackedOperationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central -> Site Calls UI: full detail for one cached call. On a repository
|
||||||
|
/// fault or missing row, <see cref="Success"/> is <c>false</c> /
|
||||||
|
/// <see cref="Detail"/> is <c>null</c> and <see cref="ErrorMessage"/> carries
|
||||||
|
/// the cause.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SiteCallDetailResponse(
|
||||||
|
string CorrelationId,
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
SiteCallDetail? Detail);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full <c>SiteCalls</c> row detail for the report detail modal — every field
|
||||||
|
/// on the <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> entity,
|
||||||
|
/// including <see cref="LastError"/> and the <see cref="IngestedAtUtc"/>
|
||||||
|
/// timestamp the grid summary omits.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SiteCallDetail(
|
||||||
|
Guid TrackedOperationId,
|
||||||
|
string SourceSite,
|
||||||
|
string Channel,
|
||||||
|
string Target,
|
||||||
|
string Status,
|
||||||
|
int RetryCount,
|
||||||
|
string? LastError,
|
||||||
|
int? HttpStatus,
|
||||||
|
DateTime CreatedAtUtc,
|
||||||
|
DateTime UpdatedAtUtc,
|
||||||
|
DateTime? TerminalAtUtc,
|
||||||
|
DateTime IngestedAtUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Calls UI -> Central: request for the global <c>SiteCalls</c> KPI summary.
|
||||||
|
/// Mirrors <see cref="ScadaLink.Commons.Messages.Notification.NotificationKpiRequest"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SiteCallKpiRequest(
|
||||||
|
string CorrelationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central -> Site Calls UI: KPI summary for the Site Calls dashboard. On a
|
||||||
|
/// repository fault <see cref="Success"/> is <c>false</c>,
|
||||||
|
/// <see cref="ErrorMessage"/> carries the cause, and the KPI fields are
|
||||||
|
/// zeroed/<c>null</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SiteCallKpiResponse(
|
||||||
|
string CorrelationId,
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
int BufferedCount,
|
||||||
|
int ParkedCount,
|
||||||
|
int FailedLastInterval,
|
||||||
|
int DeliveredLastInterval,
|
||||||
|
TimeSpan? OldestPendingAge,
|
||||||
|
int StuckCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Calls UI -> Central: request for the per-source-site <c>SiteCalls</c>
|
||||||
|
/// KPI breakdown. Mirrors
|
||||||
|
/// <see cref="ScadaLink.Commons.Messages.Notification.PerSiteNotificationKpiRequest"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PerSiteSiteCallKpiRequest(
|
||||||
|
string CorrelationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central -> Site Calls UI: per-site KPI breakdown for the Site Calls KPIs
|
||||||
|
/// page. On a repository fault <see cref="Success"/> is <c>false</c>,
|
||||||
|
/// <see cref="ErrorMessage"/> carries the cause, and <see cref="Sites"/> is empty.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PerSiteSiteCallKpiResponse(
|
||||||
|
string CorrelationId,
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
IReadOnlyList<SiteCallSiteKpiSnapshot> Sites);
|
||||||
113
src/ScadaLink.Commons/Messages/Audit/SiteCallRelayMessages.cs
Normal file
113
src/ScadaLink.Commons/Messages/Audit/SiteCallRelayMessages.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
namespace ScadaLink.Commons.Messages.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of a Site Call Audit (#22) Retry/Discard relay — distinguishes the
|
||||||
|
/// three cases the Central UI Site Calls page must surface differently.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The "site unreachable" case is deliberately separate from
|
||||||
|
/// <see cref="OperationFailed"/>: central is an eventually-consistent mirror,
|
||||||
|
/// not the source of truth, so a relay that never reaches the owning site is a
|
||||||
|
/// transient transport condition the operator can retry — not a failed
|
||||||
|
/// operation. The UI shows "site unreachable" rather than a generic error.
|
||||||
|
/// </remarks>
|
||||||
|
public enum SiteCallRelayOutcome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The owning site received the relay command and applied the action to its
|
||||||
|
/// Store-and-Forward buffer (the parked cached call was reset to retry, or
|
||||||
|
/// discarded). The corrected state reaches central later via telemetry.
|
||||||
|
/// </summary>
|
||||||
|
Applied,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The owning site received the relay command but found nothing to do — no
|
||||||
|
/// parked row matched the tracked id (already delivered/discarded, or no
|
||||||
|
/// longer <c>Parked</c>). A definitive answer from the site, not a failure.
|
||||||
|
/// </summary>
|
||||||
|
NotParked,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The owning site could not be reached (offline / no ClusterClient route /
|
||||||
|
/// relay timed out). The action was NOT applied; the operator may retry once
|
||||||
|
/// the site is back online.
|
||||||
|
/// </summary>
|
||||||
|
SiteUnreachable,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The owning site was reached but reported it could not apply the action
|
||||||
|
/// (its parked-message handler was unavailable or its store faulted).
|
||||||
|
/// </summary>
|
||||||
|
OperationFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central UI → Site Call Audit: relay a Retry of a parked cached call to its
|
||||||
|
/// owning site. The owning site performs the actual retry on its
|
||||||
|
/// Store-and-Forward buffer — central never mutates the central <c>SiteCalls</c>
|
||||||
|
/// mirror row. Mirrors
|
||||||
|
/// <see cref="ScadaLink.Commons.Messages.Notification.RetryNotificationRequest"/>
|
||||||
|
/// but carries <see cref="SourceSite"/> (the relay target) and answers with a
|
||||||
|
/// distinct site-unreachable outcome.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="CorrelationId">Request correlation id, echoed on the response.</param>
|
||||||
|
/// <param name="TrackedOperationId">
|
||||||
|
/// The cached operation to retry — the PK of the central <c>SiteCalls</c> row
|
||||||
|
/// and the S&F buffer message id at the owning site.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SourceSite">
|
||||||
|
/// The owning site (<c>SiteCall.SourceSite</c>) the relay is routed to.
|
||||||
|
/// </param>
|
||||||
|
public sealed record RetrySiteCallRequest(
|
||||||
|
string CorrelationId,
|
||||||
|
Guid TrackedOperationId,
|
||||||
|
string SourceSite);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Call Audit → Central UI: result of a <see cref="RetrySiteCallRequest"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="CorrelationId">Echoed request correlation id.</param>
|
||||||
|
/// <param name="Outcome">
|
||||||
|
/// The relay outcome — <see cref="SiteCallRelayOutcome.Applied"/>,
|
||||||
|
/// <see cref="SiteCallRelayOutcome.NotParked"/>,
|
||||||
|
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> or
|
||||||
|
/// <see cref="SiteCallRelayOutcome.OperationFailed"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Success">
|
||||||
|
/// Convenience flag — <c>true</c> only for <see cref="SiteCallRelayOutcome.Applied"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SiteReachable">
|
||||||
|
/// <c>false</c> only for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>; lets
|
||||||
|
/// the UI distinguish "site offline" from "operation failed" without switching
|
||||||
|
/// on the enum.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ErrorMessage">
|
||||||
|
/// Human-readable detail for a non-applied outcome; <c>null</c> on success.
|
||||||
|
/// </param>
|
||||||
|
public sealed record RetrySiteCallResponse(
|
||||||
|
string CorrelationId,
|
||||||
|
SiteCallRelayOutcome Outcome,
|
||||||
|
bool Success,
|
||||||
|
bool SiteReachable,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central UI → Site Call Audit: relay a Discard of a parked cached call to its
|
||||||
|
/// owning site. See <see cref="RetrySiteCallRequest"/> for the source-of-truth
|
||||||
|
/// and routing rationale.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DiscardSiteCallRequest(
|
||||||
|
string CorrelationId,
|
||||||
|
Guid TrackedOperationId,
|
||||||
|
string SourceSite);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Call Audit → Central UI: result of a <see cref="DiscardSiteCallRequest"/>.
|
||||||
|
/// Same shape as <see cref="RetrySiteCallResponse"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DiscardSiteCallResponse(
|
||||||
|
string CorrelationId,
|
||||||
|
SiteCallRelayOutcome Outcome,
|
||||||
|
bool Success,
|
||||||
|
bool SiteReachable,
|
||||||
|
string? ErrorMessage);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Messages.RemoteQuery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central → site relay command: retry a parked cached operation
|
||||||
|
/// (<c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c>) on the
|
||||||
|
/// owning site's Store-and-Forward buffer. Sent over the command/control
|
||||||
|
/// channel by <c>SiteCallAuditActor</c> when an operator clicks Retry on a
|
||||||
|
/// <c>Parked</c> Site Call row in the Central UI.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The site is the source of truth for cached-call status — central never
|
||||||
|
/// mutates the central <c>SiteCalls</c> mirror row directly. This command asks
|
||||||
|
/// the site to reset its own parked row back to <c>Pending</c> so the S&F
|
||||||
|
/// retry sweep attempts delivery again; the corrected state then flows back to
|
||||||
|
/// central via the normal cached-call telemetry path.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The cached call's S&F buffer message id is the
|
||||||
|
/// <see cref="TrackedOperationId"/> itself (the tracked id is supplied as the
|
||||||
|
/// buffered row's id at enqueue time), so the site can resolve the parked row
|
||||||
|
/// directly from <see cref="TrackedOperationId"/>. A retry on a row that is not
|
||||||
|
/// actually <c>Parked</c> is a safe no-op at the site — the ack reports
|
||||||
|
/// <c>Applied=false</c> rather than corrupting a non-parked row.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This is a plain record carrying only ids, so it lives in Commons (no
|
||||||
|
/// <c>IActorRef</c> field). It mirrors <see cref="ParkedMessageRetryRequest"/>
|
||||||
|
/// but keys on <see cref="TrackedOperationId"/> rather than the opaque S&F
|
||||||
|
/// message-id string.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record RetryParkedOperation(
|
||||||
|
string CorrelationId,
|
||||||
|
TrackedOperationId TrackedOperationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central → site relay command: discard a parked cached operation on the
|
||||||
|
/// owning site's Store-and-Forward buffer. Sent over the command/control
|
||||||
|
/// channel by <c>SiteCallAuditActor</c> when an operator clicks Discard on a
|
||||||
|
/// <c>Parked</c> Site Call row in the Central UI. See
|
||||||
|
/// <see cref="RetryParkedOperation"/> for the source-of-truth and message-id
|
||||||
|
/// rationale; Discard marks the operation terminally <c>Discarded</c> at the
|
||||||
|
/// site by removing the parked S&F buffer row.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DiscardParkedOperation(
|
||||||
|
string CorrelationId,
|
||||||
|
TrackedOperationId TrackedOperationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site → central ack for a <see cref="RetryParkedOperation"/> /
|
||||||
|
/// <see cref="DiscardParkedOperation"/> relay command. The site replies this
|
||||||
|
/// after applying (or safely no-op-ing) the action against its own
|
||||||
|
/// Store-and-Forward buffer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="CorrelationId">Correlation id of the originating relay command.</param>
|
||||||
|
/// <param name="Applied">
|
||||||
|
/// <c>true</c> when the parked operation was found and the action was applied;
|
||||||
|
/// <c>false</c> when no parked row matched the <see cref="RetryParkedOperation.TrackedOperationId"/>
|
||||||
|
/// (already delivered, discarded, never cached, or not in a <c>Parked</c>
|
||||||
|
/// state). A <c>false</c> ack is a definitive "nothing to do" answer from the
|
||||||
|
/// site — it is NOT a transport failure, so the relay must distinguish it from
|
||||||
|
/// a site-unreachable timeout.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ErrorMessage">
|
||||||
|
/// Populated only when the site could not apply the action (e.g. the parked
|
||||||
|
/// message handler is not available, or the S&F store faulted); <c>null</c>
|
||||||
|
/// on a clean <c>Applied=true</c>/<c>Applied=false</c> outcome.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ParkedOperationActionAck(
|
||||||
|
string CorrelationId,
|
||||||
|
bool Applied,
|
||||||
|
string? ErrorMessage = null);
|
||||||
@@ -4,16 +4,20 @@ namespace ScadaLink.Commons.Types.Audit;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
||||||
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
|
/// Any field left <c>null</c> means "do not constrain on that column". The
|
||||||
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
|
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
|
||||||
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
|
/// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
|
||||||
/// <c>>=</c> / <c><=</c> respectively. All filter fields are AND-combined.
|
/// list means "do not constrain", and a non-empty list is OR-combined within the
|
||||||
|
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
|
||||||
|
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
|
||||||
|
/// inclusive of the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||||
|
/// respectively. All filter dimensions are AND-combined with one another.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record AuditLogQueryFilter(
|
public sealed record AuditLogQueryFilter(
|
||||||
AuditChannel? Channel = null,
|
IReadOnlyList<AuditChannel>? Channels = null,
|
||||||
AuditKind? Kind = null,
|
IReadOnlyList<AuditKind>? Kinds = null,
|
||||||
AuditStatus? Status = null,
|
IReadOnlyList<AuditStatus>? Statuses = null,
|
||||||
string? SourceSiteId = null,
|
IReadOnlyList<string>? SourceSiteIds = null,
|
||||||
string? Target = null,
|
string? Target = null,
|
||||||
string? Actor = null,
|
string? Actor = null,
|
||||||
Guid? CorrelationId = null,
|
Guid? CorrelationId = null,
|
||||||
|
|||||||
79
src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs
Normal file
79
src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared lax parsers for the multi-value Audit Log query parameters
|
||||||
|
/// (<c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c>). The Audit Log filter
|
||||||
|
/// wire-contract is consumed by three surfaces that MUST stay in lockstep:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>the ManagementService <c>/api/audit/query</c> + <c>/api/audit/export</c>
|
||||||
|
/// endpoints,</item>
|
||||||
|
/// <item>the CentralUI <c>/api/centralui/audit/export</c> endpoint, and</item>
|
||||||
|
/// <item>the CentralUI <c>AuditLogPage</c> query-string drill-in parser.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Each caller extracts the raw repeated values for a single parameter from its
|
||||||
|
/// own request type (ASP.NET <c>IQueryCollection</c>, a
|
||||||
|
/// <c>Dictionary<string, StringValues></c> from <c>QueryHelpers.ParseQuery</c>,
|
||||||
|
/// etc.) and passes them here as a plain <see cref="IEnumerable{T}"/> of strings —
|
||||||
|
/// so this helper carries NO ASP.NET / <c>Microsoft.Extensions.Primitives</c>
|
||||||
|
/// dependency and can live in <c>ScadaLink.Commons</c>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Lax-parse contract.</b> Every value of a repeated parameter is parsed
|
||||||
|
/// independently; an unparseable or blank element is silently dropped (NO 400)
|
||||||
|
/// rather than failing the whole set. An empty result collapses to <c>null</c> so
|
||||||
|
/// the corresponding filter dimension stays unconstrained.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditQueryParamParsers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses each raw value as <typeparamref name="TEnum"/> (case-insensitive),
|
||||||
|
/// dropping unparseable values silently. Returns <c>null</c> when
|
||||||
|
/// <paramref name="rawValues"/> is <c>null</c>, empty, or yields no parseable
|
||||||
|
/// value — so the filter dimension stays unconstrained.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IEnumerable<string?>? rawValues)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (rawValues is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = new List<TEnum>();
|
||||||
|
foreach (var raw in rawValues)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
||||||
|
{
|
||||||
|
parsed.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed.Count > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trims each raw value and drops blank entries. Returns <c>null</c> when
|
||||||
|
/// <paramref name="rawValues"/> is <c>null</c>, empty, or every value was
|
||||||
|
/// blank.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string>? ParseStringList(IEnumerable<string?>? rawValues)
|
||||||
|
{
|
||||||
|
if (rawValues is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = new List<string>();
|
||||||
|
foreach (var raw in rawValues)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
parsed.Add(raw.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed.Count > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/ScadaLink.Commons/Types/Audit/SiteCallKpiSnapshot.cs
Normal file
38
src/ScadaLink.Commons/Types/Audit/SiteCallKpiSnapshot.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Point-in-time operational metrics for the central <c>SiteCalls</c> table
|
||||||
|
/// (Site Call Audit #22), surfaced on the health dashboard. The cached-call
|
||||||
|
/// counterpart of <see cref="ScadaLink.Commons.Types.Notifications.NotificationKpiSnapshot"/>;
|
||||||
|
/// mirrors its shape so the Central UI Site Calls KPI tiles can reuse the
|
||||||
|
/// Notification Outbox tile layout.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="BufferedCount">
|
||||||
|
/// Count of non-terminal rows (<c>TerminalAtUtc IS NULL</c>) — calls
|
||||||
|
/// buffered at sites awaiting retry.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ParkedCount">Count of rows in the <c>Parked</c> status.</param>
|
||||||
|
/// <param name="FailedLastInterval">
|
||||||
|
/// Count of <c>Failed</c> rows whose <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.TerminalAtUtc"/>
|
||||||
|
/// is at or after the supplied "since" timestamp.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DeliveredLastInterval">
|
||||||
|
/// Count of <c>Delivered</c> rows whose <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.TerminalAtUtc"/>
|
||||||
|
/// is at or after the supplied "since" timestamp.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="OldestPendingAge">
|
||||||
|
/// Age of the oldest non-terminal row (<c>now - min(CreatedAtUtc)</c>), or
|
||||||
|
/// <c>null</c> when there are no non-terminal rows.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="StuckCount">
|
||||||
|
/// Count of non-terminal rows (<c>TerminalAtUtc IS NULL</c>) whose
|
||||||
|
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall.CreatedAtUtc"/> is older
|
||||||
|
/// than the supplied stuck cutoff. Display-only — no escalation.
|
||||||
|
/// </param>
|
||||||
|
public sealed record SiteCallKpiSnapshot(
|
||||||
|
int BufferedCount,
|
||||||
|
int ParkedCount,
|
||||||
|
int FailedLastInterval,
|
||||||
|
int DeliveredLastInterval,
|
||||||
|
TimeSpan? OldestPendingAge,
|
||||||
|
int StuckCount);
|
||||||
@@ -12,10 +12,25 @@ namespace ScadaLink.Commons.Types.Audit;
|
|||||||
/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
|
/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
|
||||||
/// page exposes them as drop-down filters, not free-text search.
|
/// page exposes them as drop-down filters, not free-text search.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <param name="Channel">Restrict to a single channel (exact match).</param>
|
||||||
|
/// <param name="SourceSite">Restrict to a single source site (exact match).</param>
|
||||||
|
/// <param name="Status">Restrict to a single status (exact match).</param>
|
||||||
|
/// <param name="Target">Restrict to a single target (exact match).</param>
|
||||||
|
/// <param name="FromUtc">Inclusive lower bound on <c>CreatedAtUtc</c>.</param>
|
||||||
|
/// <param name="ToUtc">Inclusive upper bound on <c>CreatedAtUtc</c>.</param>
|
||||||
|
/// <param name="StuckCutoffUtc">
|
||||||
|
/// When set, restrict to stuck rows: <c>TerminalAtUtc IS NULL AND CreatedAtUtc <
|
||||||
|
/// StuckCutoffUtc</c>. Both columns are plain (no value converter) and compose
|
||||||
|
/// directly with the keyset cursor. Mirrors
|
||||||
|
/// <see cref="ScadaLink.Commons.Types.Notifications.NotificationOutboxFilter.StuckCutoff"/>;
|
||||||
|
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
|
||||||
|
/// pages with a non-null next cursor.
|
||||||
|
/// </param>
|
||||||
public sealed record SiteCallQueryFilter(
|
public sealed record SiteCallQueryFilter(
|
||||||
string? Channel = null,
|
string? Channel = null,
|
||||||
string? SourceSite = null,
|
string? SourceSite = null,
|
||||||
string? Status = null,
|
string? Status = null,
|
||||||
string? Target = null,
|
string? Target = null,
|
||||||
DateTime? FromUtc = null,
|
DateTime? FromUtc = null,
|
||||||
DateTime? ToUtc = null);
|
DateTime? ToUtc = null,
|
||||||
|
DateTime? StuckCutoffUtc = null);
|
||||||
|
|||||||
34
src/ScadaLink.Commons/Types/Audit/SiteCallSiteKpiSnapshot.cs
Normal file
34
src/ScadaLink.Commons/Types/Audit/SiteCallSiteKpiSnapshot.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Point-in-time <c>SiteCalls</c> metrics scoped to a single source site. The
|
||||||
|
/// per-site counterpart of <see cref="SiteCallKpiSnapshot"/>; surfaced in the
|
||||||
|
/// per-site breakdown table on the Site Calls KPIs page. Mirrors
|
||||||
|
/// <see cref="ScadaLink.Commons.Types.Notifications.SiteNotificationKpiSnapshot"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="SourceSite">The site identifier these metrics are scoped to.</param>
|
||||||
|
/// <param name="BufferedCount">Count of this site's non-terminal rows (<c>TerminalAtUtc IS NULL</c>).</param>
|
||||||
|
/// <param name="ParkedCount">Count of this site's rows in the <c>Parked</c> status.</param>
|
||||||
|
/// <param name="FailedLastInterval">
|
||||||
|
/// Count of this site's <c>Failed</c> rows whose <c>TerminalAtUtc</c> is at or
|
||||||
|
/// after the "since" timestamp.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DeliveredLastInterval">
|
||||||
|
/// Count of this site's <c>Delivered</c> rows whose <c>TerminalAtUtc</c> is at
|
||||||
|
/// or after the "since" timestamp.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="OldestPendingAge">
|
||||||
|
/// Age of this site's oldest non-terminal row, or <c>null</c> when it has none.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="StuckCount">
|
||||||
|
/// Count of this site's non-terminal rows whose <c>CreatedAtUtc</c> is older
|
||||||
|
/// than the stuck cutoff.
|
||||||
|
/// </param>
|
||||||
|
public sealed record SiteCallSiteKpiSnapshot(
|
||||||
|
string SourceSite,
|
||||||
|
int BufferedCount,
|
||||||
|
int ParkedCount,
|
||||||
|
int FailedLastInterval,
|
||||||
|
int DeliveredLastInterval,
|
||||||
|
TimeSpan? OldestPendingAge,
|
||||||
|
int StuckCount);
|
||||||
@@ -5,6 +5,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
|
|||||||
using Akka.Event;
|
using Akka.Event;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
using ScadaLink.Commons.Messages.Communication;
|
using ScadaLink.Commons.Messages.Communication;
|
||||||
using ScadaLink.Commons.Messages.Health;
|
using ScadaLink.Commons.Messages.Health;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
@@ -76,6 +77,43 @@ public class CentralCommunicationActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private IActorRef? _notificationOutboxProxy;
|
private IActorRef? _notificationOutboxProxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proxy <see cref="IActorRef"/> for the central AuditLogIngestActor cluster
|
||||||
|
/// singleton. Set via <see cref="RegisterAuditIngest"/> — the Host creates the
|
||||||
|
/// singleton proxy after this actor and registers it (mirrors
|
||||||
|
/// <see cref="_notificationOutboxProxy"/>). Null until registration completes;
|
||||||
|
/// an audit ingest command arriving before then is answered with an empty
|
||||||
|
/// reply so the site keeps its rows Pending and retries.
|
||||||
|
///
|
||||||
|
/// Once registered, the handler Asks this proxy and pipes the reply straight
|
||||||
|
/// back to the caller. On an Ask timeout or a faulted reply, PipeTo forwards a
|
||||||
|
/// <see cref="Status.Failure"/> to the caller — the fault propagates rather
|
||||||
|
/// than being swallowed. This differs from the gRPC handler
|
||||||
|
/// (<c>SiteStreamGrpcServer</c>), which catches the exception and returns an
|
||||||
|
/// empty ack; here the faulted Ask is the transient signal the site relies on
|
||||||
|
/// (see <see cref="HandleIngestAuditEvents"/>).
|
||||||
|
/// </summary>
|
||||||
|
private IActorRef? _auditIngestProxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default Ask timeout for routing audit ingest commands to the
|
||||||
|
/// AuditLogIngestActor proxy — 30 s, matching the value of
|
||||||
|
/// <c>SiteStreamGrpcServer.AuditIngestAskTimeout</c> (that constant is private
|
||||||
|
/// to the gRPC server and not reachable here, so it is declared locally). A
|
||||||
|
/// generous window absorbs a slow MS SQL connection without the round-trip
|
||||||
|
/// surfacing as a failure on a healthy site. When the window is exceeded the
|
||||||
|
/// Ask faults and that fault is piped back to the caller as a
|
||||||
|
/// <see cref="Status.Failure"/> (see <see cref="HandleIngestAuditEvents"/>).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan DefaultAuditIngestAskTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Effective Ask timeout for audit ingest routing. Defaults to
|
||||||
|
/// <see cref="DefaultAuditIngestAskTimeout"/>; overridable via the constructor
|
||||||
|
/// so tests can exercise the timeout/fault path without waiting 30 s.
|
||||||
|
/// </summary>
|
||||||
|
private readonly TimeSpan _auditIngestAskTimeout;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DistributedPubSub topic used to fan health reports out to the peer
|
/// DistributedPubSub topic used to fan health reports out to the peer
|
||||||
/// central node so both per-node aggregators stay in sync. See
|
/// central node so both per-node aggregators stay in sync. See
|
||||||
@@ -83,10 +121,19 @@ public class CentralCommunicationActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const string HealthReportTopic = "site-health-replica";
|
private const string HealthReportTopic = "site-health-replica";
|
||||||
|
|
||||||
public CentralCommunicationActor(IServiceProvider serviceProvider, ISiteClientFactory siteClientFactory)
|
/// <param name="auditIngestAskTimeout">
|
||||||
|
/// Optional override for the audit-ingest Ask timeout; defaults to
|
||||||
|
/// <see cref="DefaultAuditIngestAskTimeout"/> (30 s). Exists only so tests can
|
||||||
|
/// exercise the timeout/fault path quickly — production always uses the default.
|
||||||
|
/// </param>
|
||||||
|
public CentralCommunicationActor(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ISiteClientFactory siteClientFactory,
|
||||||
|
TimeSpan? auditIngestAskTimeout = null)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_siteClientFactory = siteClientFactory;
|
_siteClientFactory = siteClientFactory;
|
||||||
|
_auditIngestAskTimeout = auditIngestAskTimeout ?? DefaultAuditIngestAskTimeout;
|
||||||
|
|
||||||
// Site address cache loaded from database
|
// Site address cache loaded from database
|
||||||
Receive<SiteAddressCacheLoaded>(HandleSiteAddressCacheLoaded);
|
Receive<SiteAddressCacheLoaded>(HandleSiteAddressCacheLoaded);
|
||||||
@@ -133,6 +180,24 @@ public class CentralCommunicationActor : ReceiveActor
|
|||||||
// so the NotificationStatusResponse routes back to the querying site.
|
// so the NotificationStatusResponse routes back to the querying site.
|
||||||
Receive<NotificationStatusQuery>(HandleNotificationStatusQuery);
|
Receive<NotificationStatusQuery>(HandleNotificationStatusQuery);
|
||||||
|
|
||||||
|
// Audit Log (#23): the Host registers the AuditLogIngestActor singleton
|
||||||
|
// proxy after this actor is created (the proxy cannot exist before this
|
||||||
|
// actor's construction).
|
||||||
|
Receive<RegisterAuditIngest>(msg =>
|
||||||
|
{
|
||||||
|
_auditIngestProxy = msg.AuditIngestActor;
|
||||||
|
_log.Info("Registered audit ingest proxy");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit Log (#23) site→central ingest: a site forwards a batch of audit
|
||||||
|
// events to the central cluster via ClusterClient. Ask the ingest proxy
|
||||||
|
// and pipe the IngestAuditEventsReply back to the original Sender (the
|
||||||
|
// site's ClusterClient path) so the site can flip its rows to Forwarded.
|
||||||
|
Receive<IngestAuditEventsCommand>(HandleIngestAuditEvents);
|
||||||
|
|
||||||
|
// Audit Log (#23 M3) combined-telemetry ingest: routes to the same proxy
|
||||||
|
// the same way; the proxy replies with an IngestCachedTelemetryReply.
|
||||||
|
Receive<IngestCachedTelemetryCommand>(HandleIngestCachedTelemetry);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleNotificationSubmit(NotificationSubmit msg)
|
private void HandleNotificationSubmit(NotificationSubmit msg)
|
||||||
@@ -172,6 +237,51 @@ public class CentralCommunicationActor : ReceiveActor
|
|||||||
_notificationOutboxProxy.Forward(msg);
|
_notificationOutboxProxy.Forward(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleIngestAuditEvents(IngestAuditEventsCommand msg)
|
||||||
|
{
|
||||||
|
if (_auditIngestProxy == null)
|
||||||
|
{
|
||||||
|
// No ingest proxy registered yet (host startup race). Reply with an
|
||||||
|
// empty IngestAuditEventsReply so the site keeps its rows Pending and
|
||||||
|
// retries — the same behaviour as the gRPC handler's wiring-race path.
|
||||||
|
_log.Warning(
|
||||||
|
"Cannot route IngestAuditEventsCommand ({0} events) — audit ingest not available",
|
||||||
|
msg.Events.Count);
|
||||||
|
Sender.Tell(new IngestAuditEventsReply(Array.Empty<Guid>()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture Sender before the async/PipeTo — Akka resets Sender between
|
||||||
|
// dispatches. The reply is piped straight back to the site's ClusterClient.
|
||||||
|
// On an Ask timeout or a faulted reply, PipeTo delivers a Status.Failure to
|
||||||
|
// replyTo: the fault propagates to the caller rather than being swallowed.
|
||||||
|
// The site's own Ask through this path then faults, and the site drain loop
|
||||||
|
// treats that as a transient failure — rows stay Pending and are retried on
|
||||||
|
// the next tick. (The gRPC handler instead returns an empty ack on fault;
|
||||||
|
// propagating the fault here is the cleaner transient signal.)
|
||||||
|
var replyTo = Sender;
|
||||||
|
_log.Debug("Routing IngestAuditEventsCommand ({0} events) to the audit ingest actor", msg.Events.Count);
|
||||||
|
_auditIngestProxy.Ask<IngestAuditEventsReply>(msg, _auditIngestAskTimeout)
|
||||||
|
.PipeTo(replyTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleIngestCachedTelemetry(IngestCachedTelemetryCommand msg)
|
||||||
|
{
|
||||||
|
if (_auditIngestProxy == null)
|
||||||
|
{
|
||||||
|
_log.Warning(
|
||||||
|
"Cannot route IngestCachedTelemetryCommand ({0} entries) — audit ingest not available",
|
||||||
|
msg.Entries.Count);
|
||||||
|
Sender.Tell(new IngestCachedTelemetryReply(Array.Empty<Guid>()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var replyTo = Sender;
|
||||||
|
_log.Debug("Routing IngestCachedTelemetryCommand ({0} entries) to the audit ingest actor", msg.Entries.Count);
|
||||||
|
_auditIngestProxy.Ask<IngestCachedTelemetryReply>(msg, _auditIngestAskTimeout)
|
||||||
|
.PipeTo(replyTo);
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleHeartbeat(HeartbeatMessage heartbeat)
|
private void HandleHeartbeat(HeartbeatMessage heartbeat)
|
||||||
{
|
{
|
||||||
var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>();
|
var aggregator = _serviceProvider.GetService<ICentralHealthAggregator>();
|
||||||
@@ -464,3 +574,14 @@ public record DebugStreamTerminated(string SiteId, string CorrelationId);
|
|||||||
/// after the outbox singleton proxy is created.
|
/// after the outbox singleton proxy is created.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record RegisterNotificationOutbox(IActorRef OutboxProxy);
|
public record RegisterNotificationOutbox(IActorRef OutboxProxy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the central AuditLogIngestActor singleton proxy with the
|
||||||
|
/// <see cref="CentralCommunicationActor"/> so site-forwarded
|
||||||
|
/// <see cref="IngestAuditEventsCommand"/> and <see cref="IngestCachedTelemetryCommand"/>
|
||||||
|
/// messages can be routed to it. Sent by the Host after the audit-ingest
|
||||||
|
/// singleton proxy is created. Lives here (not in Commons) because
|
||||||
|
/// <c>ScadaLink.Commons</c> has no Akka package reference and cannot hold an
|
||||||
|
/// <see cref="IActorRef"/> field.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RegisterAuditIngest(IActorRef AuditIngestActor);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
|||||||
using Akka.Cluster.Tools.Client;
|
using Akka.Cluster.Tools.Client;
|
||||||
using Akka.Event;
|
using Akka.Event;
|
||||||
using ScadaLink.Commons.Messages.Artifacts;
|
using ScadaLink.Commons.Messages.Artifacts;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
using ScadaLink.Commons.Messages.DebugView;
|
using ScadaLink.Commons.Messages.DebugView;
|
||||||
using ScadaLink.Commons.Messages.Deployment;
|
using ScadaLink.Commons.Messages.Deployment;
|
||||||
using ScadaLink.Commons.Messages.Health;
|
using ScadaLink.Commons.Messages.Health;
|
||||||
@@ -166,6 +167,33 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Task 5 (#22): central→site Retry/Discard relay for parked cached
|
||||||
|
// operations. SiteCallAuditActor relays these over the command/control
|
||||||
|
// channel; the parked-message handler executes them against the local
|
||||||
|
// S&F buffer and replies a ParkedOperationActionAck that routes back to
|
||||||
|
// the relaying SiteCallAuditActor's Ask.
|
||||||
|
Receive<RetryParkedOperation>(msg =>
|
||||||
|
{
|
||||||
|
if (_parkedMessageHandler != null)
|
||||||
|
_parkedMessageHandler.Forward(msg);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Sender.Tell(new ParkedOperationActionAck(
|
||||||
|
msg.CorrelationId, Applied: false, "Parked message handler not available"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Receive<DiscardParkedOperation>(msg =>
|
||||||
|
{
|
||||||
|
if (_parkedMessageHandler != null)
|
||||||
|
_parkedMessageHandler.Forward(msg);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Sender.Tell(new ParkedOperationActionAck(
|
||||||
|
msg.CorrelationId, Applied: false, "Parked message handler not available"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Notification Outbox: forward a buffered notification submitted by the site
|
// Notification Outbox: forward a buffered notification submitted by the site
|
||||||
// Store-and-Forward Engine to the central cluster. The original Sender (the
|
// Store-and-Forward Engine to the central cluster. The original Sender (the
|
||||||
// S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the
|
// S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the
|
||||||
@@ -214,6 +242,54 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
|
|||||||
new ClusterClient.Send("/user/central-communication", msg), Sender);
|
new ClusterClient.Send("/user/central-communication", msg), Sender);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit Log (#23): forward a batch of site-local audit events to the
|
||||||
|
// central cluster. The site SiteAuditTelemetryActor drains its SQLite
|
||||||
|
// Pending queue through the ClusterClientSiteAuditClient, which Asks
|
||||||
|
// this actor; the original Sender (that Ask) is passed as the
|
||||||
|
// ClusterClient.Send sender so the IngestAuditEventsReply routes
|
||||||
|
// straight back to the waiting Ask, not here. Mirrors NotificationSubmit.
|
||||||
|
Receive<IngestAuditEventsCommand>(msg =>
|
||||||
|
{
|
||||||
|
if (_centralClient == null)
|
||||||
|
{
|
||||||
|
// No ClusterClient registered yet (e.g. central contact points
|
||||||
|
// not configured, or registration not yet completed). Faulting
|
||||||
|
// the Ask makes the SiteAuditTelemetryActor drain loop treat
|
||||||
|
// this as transient and keep the rows Pending for the next tick.
|
||||||
|
_log.Warning(
|
||||||
|
"Cannot forward IngestAuditEventsCommand ({0} events) — no central ClusterClient registered",
|
||||||
|
msg.Events.Count);
|
||||||
|
Sender.Tell(new Status.Failure(
|
||||||
|
new InvalidOperationException("Central ClusterClient not registered")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.Debug("Forwarding IngestAuditEventsCommand ({0} events) to central", msg.Events.Count);
|
||||||
|
_centralClient.Tell(
|
||||||
|
new ClusterClient.Send("/user/central-communication", msg), Sender);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit Log (#23) M3: forward a batch of combined cached-call telemetry
|
||||||
|
// packets to the central cluster. Same forward + reply-routing pattern
|
||||||
|
// as IngestAuditEventsCommand; central replies with an
|
||||||
|
// IngestCachedTelemetryReply.
|
||||||
|
Receive<IngestCachedTelemetryCommand>(msg =>
|
||||||
|
{
|
||||||
|
if (_centralClient == null)
|
||||||
|
{
|
||||||
|
_log.Warning(
|
||||||
|
"Cannot forward IngestCachedTelemetryCommand ({0} entries) — no central ClusterClient registered",
|
||||||
|
msg.Entries.Count);
|
||||||
|
Sender.Tell(new Status.Failure(
|
||||||
|
new InvalidOperationException("Central ClusterClient not registered")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.Debug("Forwarding IngestCachedTelemetryCommand ({0} entries) to central", msg.Entries.Count);
|
||||||
|
_centralClient.Tell(
|
||||||
|
new ClusterClient.Send("/user/central-communication", msg), Sender);
|
||||||
|
});
|
||||||
|
|
||||||
// Internal: send heartbeat tick
|
// Internal: send heartbeat tick
|
||||||
Receive<SendHeartbeat>(_ => SendHeartbeatToCentral());
|
Receive<SendHeartbeat>(_ => SendHeartbeatToCentral());
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Akka.Actor;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ScadaLink.Commons.Messages.Artifacts;
|
using ScadaLink.Commons.Messages.Artifacts;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
using ScadaLink.Commons.Messages.DebugView;
|
using ScadaLink.Commons.Messages.DebugView;
|
||||||
using ScadaLink.Commons.Messages.Deployment;
|
using ScadaLink.Commons.Messages.Deployment;
|
||||||
using ScadaLink.Commons.Messages.Health;
|
using ScadaLink.Commons.Messages.Health;
|
||||||
@@ -25,6 +26,7 @@ public class CommunicationService
|
|||||||
private readonly ILogger<CommunicationService> _logger;
|
private readonly ILogger<CommunicationService> _logger;
|
||||||
private IActorRef? _centralCommunicationActor;
|
private IActorRef? _centralCommunicationActor;
|
||||||
private IActorRef? _notificationOutboxProxy;
|
private IActorRef? _notificationOutboxProxy;
|
||||||
|
private IActorRef? _siteCallAuditProxy;
|
||||||
|
|
||||||
public CommunicationService(
|
public CommunicationService(
|
||||||
IOptions<CommunicationOptions> options,
|
IOptions<CommunicationOptions> options,
|
||||||
@@ -52,6 +54,17 @@ public class CommunicationService
|
|||||||
_notificationOutboxProxy = notificationOutboxProxy;
|
_notificationOutboxProxy = notificationOutboxProxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the Site Call Audit (#22) singleton proxy reference. Called during
|
||||||
|
/// actor system startup. The Site Call Audit actor is central-local, so Site
|
||||||
|
/// Calls read calls Ask this proxy directly (no SiteEnvelope routing), the
|
||||||
|
/// same pattern as <see cref="SetNotificationOutbox"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void SetSiteCallAudit(IActorRef siteCallAuditProxy)
|
||||||
|
{
|
||||||
|
_siteCallAuditProxy = siteCallAuditProxy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Triggers an immediate refresh of the site address cache from the database.
|
/// Triggers an immediate refresh of the site address cache from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -80,6 +93,15 @@ public class CommunicationService
|
|||||||
?? throw new InvalidOperationException("CommunicationService not initialized. NotificationOutbox proxy not set.");
|
?? throw new InvalidOperationException("CommunicationService not initialized. NotificationOutbox proxy not set.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Site Call Audit proxy reference. Throws if not yet initialized.
|
||||||
|
/// </summary>
|
||||||
|
private IActorRef GetSiteCallAudit()
|
||||||
|
{
|
||||||
|
return _siteCallAuditProxy
|
||||||
|
?? throw new InvalidOperationException("CommunicationService not initialized. SiteCallAudit proxy not set.");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pattern 1: Instance Deployment ──
|
// ── Pattern 1: Instance Deployment ──
|
||||||
|
|
||||||
public async Task<DeploymentStatusResponse> DeployInstanceAsync(
|
public async Task<DeploymentStatusResponse> DeployInstanceAsync(
|
||||||
@@ -295,6 +317,71 @@ public class CommunicationService
|
|||||||
return await GetNotificationOutbox().Ask<PerSiteNotificationKpiResponse>(
|
return await GetNotificationOutbox().Ask<PerSiteNotificationKpiResponse>(
|
||||||
request, _options.QueryTimeout, cancellationToken);
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Site Call Audit (central-local actor — Asked directly, no SiteEnvelope) ──
|
||||||
|
|
||||||
|
public async Task<SiteCallQueryResponse> QuerySiteCallsAsync(
|
||||||
|
SiteCallQueryRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetSiteCallAudit().Ask<SiteCallQueryResponse>(
|
||||||
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SiteCallDetailResponse> GetSiteCallDetailAsync(
|
||||||
|
SiteCallDetailRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetSiteCallAudit().Ask<SiteCallDetailResponse>(
|
||||||
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SiteCallKpiResponse> GetSiteCallKpisAsync(
|
||||||
|
SiteCallKpiRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetSiteCallAudit().Ask<SiteCallKpiResponse>(
|
||||||
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PerSiteSiteCallKpiResponse> GetPerSiteSiteCallKpisAsync(
|
||||||
|
PerSiteSiteCallKpiRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetSiteCallAudit().Ask<PerSiteSiteCallKpiResponse>(
|
||||||
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 5 (#22): relays an operator Retry of a parked cached call to its
|
||||||
|
/// owning site. The <c>SiteCallAuditActor</c> is Asked directly (it is
|
||||||
|
/// central-local); it in turn relays a <c>RetryParkedOperation</c> to the
|
||||||
|
/// owning site and replies a <see cref="RetrySiteCallResponse"/> carrying a
|
||||||
|
/// distinct site-unreachable outcome. Central never mutates the central
|
||||||
|
/// <c>SiteCalls</c> mirror row.
|
||||||
|
/// <para>
|
||||||
|
/// This outer Ask uses <see cref="CommunicationOptions.QueryTimeout"/>
|
||||||
|
/// (default 30s), which must outlive the inner site relay Ask the
|
||||||
|
/// <c>SiteCallAuditActor</c> issues with <c>SiteCallAuditOptions.RelayTimeout</c>
|
||||||
|
/// (default 10s). The inner relay must time out first so its distinct
|
||||||
|
/// <c>SiteUnreachable</c> outcome reaches us; were this outer Ask to expire
|
||||||
|
/// first, that outcome would be lost to a generic Ask-timeout exception.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RetrySiteCallResponse> RetrySiteCallAsync(
|
||||||
|
RetrySiteCallRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetSiteCallAudit().Ask<RetrySiteCallResponse>(
|
||||||
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 5 (#22): relays an operator Discard of a parked cached call to its
|
||||||
|
/// owning site. See <see cref="RetrySiteCallAsync"/> for the routing and
|
||||||
|
/// source-of-truth rationale.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DiscardSiteCallResponse> DiscardSiteCallAsync(
|
||||||
|
DiscardSiteCallRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetSiteCallAudit().Ask<DiscardSiteCallResponse>(
|
||||||
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.Communication.Grpc;
|
|
||||||
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;
|
||||||
|
|
||||||
namespace ScadaLink.AuditLog.Telemetry;
|
namespace ScadaLink.Communication.Grpc;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bridges Audit Log (#23) rows between the in-process <see cref="AuditEvent"/> record
|
/// Canonical bridge for Audit Log (#23) rows between the in-process
|
||||||
/// and the wire-format <see cref="AuditEventDto"/> exchanged over the
|
/// <see cref="AuditEvent"/> record and the wire-format <see cref="AuditEventDto"/>
|
||||||
/// <c>IngestAuditEvents</c> RPC.
|
/// exchanged over the <c>IngestAuditEvents</c>, <c>IngestCachedTelemetry</c> and
|
||||||
|
/// <c>PullAuditEvents</c> RPCs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This mapper lives in <c>ScadaLink.Communication</c> (which owns the generated
|
||||||
|
/// <see cref="AuditEventDto"/> and references <c>Commons</c> for
|
||||||
|
/// <see cref="AuditEvent"/>) so both <c>SiteStreamGrpcServer</c> and
|
||||||
|
/// <c>ScadaLink.AuditLog</c> can share one implementation without the
|
||||||
|
/// project-reference cycle that would result from hosting it in
|
||||||
|
/// <c>ScadaLink.AuditLog</c> (AuditLog → Communication, never the reverse).
|
||||||
|
/// </para>
|
||||||
/// <para><b>Lossy by design:</b> the proto contract intentionally omits two fields.</para>
|
/// <para><b>Lossy by design:</b> the proto contract intentionally omits two fields.</para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><see cref="AuditEvent.ForwardState"/> — site-local SQLite state, never travels.</item>
|
/// <item><see cref="AuditEvent.ForwardState"/> — site-local SQLite state, never travels.</item>
|
||||||
@@ -22,7 +30,7 @@ namespace ScadaLink.AuditLog.Telemetry;
|
|||||||
/// <c>Int32Value</c> wrapper so they preserve true null semantics.
|
/// <c>Int32Value</c> wrapper so they preserve true null semantics.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class AuditEventMapper
|
public static class AuditEventDtoMapper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Projects an <see cref="AuditEvent"/> into its wire-format DTO. Null reference
|
/// Projects an <see cref="AuditEvent"/> into its wire-format DTO. Null reference
|
||||||
70
src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs
Normal file
70
src/ScadaLink.Communication/Grpc/SiteCallDtoMapper.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
|
||||||
|
namespace ScadaLink.Communication.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical bridge for Site Call Audit (#22) operational rows between the
|
||||||
|
/// wire-format <see cref="SiteCallOperationalDto"/> exchanged on the
|
||||||
|
/// <c>CachedCallTelemetry</c> packet and the in-process <see cref="SiteCall"/>
|
||||||
|
/// persistence entity central writes into the <c>SiteCalls</c> table.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This mapper lives in <c>ScadaLink.Communication</c> (which owns the generated
|
||||||
|
/// <see cref="SiteCallOperationalDto"/> and references <c>Commons</c> for
|
||||||
|
/// <see cref="SiteCall"/>) so both <c>SiteStreamGrpcServer</c> and
|
||||||
|
/// <c>ScadaLink.AuditLog</c> can share one implementation without the
|
||||||
|
/// project-reference cycle that would result from hosting it in
|
||||||
|
/// <c>ScadaLink.AuditLog</c> (AuditLog → Communication, never the reverse).
|
||||||
|
/// Mirrors the sibling <see cref="AuditEventDtoMapper"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only the DTO→entity direction is provided: nothing in the system maps a
|
||||||
|
/// <see cref="SiteCall"/> back onto the wire (sites emit the operational state
|
||||||
|
/// from <c>SiteCallOperational</c>, never from the central <see cref="SiteCall"/>
|
||||||
|
/// entity), so an entity→DTO method would be dead code.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// String nullability convention: proto3 scalar strings cannot be absent, so the
|
||||||
|
/// optional <see cref="SiteCall.LastError"/> rehydrates from an empty string back
|
||||||
|
/// to null. The optional <c>HttpStatus</c> and <c>TerminalAtUtc</c> use proto
|
||||||
|
/// wrappers so they preserve true null semantics.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class SiteCallDtoMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reconstructs a <see cref="SiteCall"/> persistence entity from its
|
||||||
|
/// wire-format DTO. An empty <c>LastError</c> rehydrates as null; absent
|
||||||
|
/// <c>HttpStatus</c>/<c>TerminalAtUtc</c> wrappers stay null.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="SiteCall.IngestedAtUtc"/> is stamped here as a placeholder
|
||||||
|
/// (<see cref="DateTime.UtcNow"/>); the central ingest actor overwrites it
|
||||||
|
/// inside the dual-write transaction so the AuditLog and SiteCalls rows
|
||||||
|
/// share one instant. The value sent on the wire is informational only.
|
||||||
|
/// </remarks>
|
||||||
|
public static SiteCall FromDto(SiteCallOperationalDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new SiteCall
|
||||||
|
{
|
||||||
|
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
|
||||||
|
Channel = dto.Channel,
|
||||||
|
Target = dto.Target,
|
||||||
|
SourceSite = dto.SourceSite,
|
||||||
|
Status = dto.Status,
|
||||||
|
RetryCount = dto.RetryCount,
|
||||||
|
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
|
||||||
|
HttpStatus = dto.HttpStatus,
|
||||||
|
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||||
|
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||||
|
TerminalAtUtc = dto.TerminalAtUtc is null
|
||||||
|
? null
|
||||||
|
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
|
||||||
|
IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ using Microsoft.Extensions.Options;
|
|||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.Audit;
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
using ScadaLink.Commons.Types;
|
|
||||||
using ScadaLink.Commons.Types.Enums;
|
|
||||||
using GrpcStatus = Grpc.Core.Status;
|
using GrpcStatus = Grpc.Core.Status;
|
||||||
|
|
||||||
namespace ScadaLink.Communication.Grpc;
|
namespace ScadaLink.Communication.Grpc;
|
||||||
@@ -224,13 +222,10 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The DTO→entity conversion is inlined here (rather than calling the
|
/// The DTO→entity conversion uses the shared <see cref="AuditEventDtoMapper"/>
|
||||||
/// AuditLog mapper) to avoid a project-reference cycle:
|
/// (hosted in <c>ScadaLink.Communication</c> so both this server and
|
||||||
/// <c>ScadaLink.AuditLog</c> already references
|
/// <c>ScadaLink.AuditLog</c> share one implementation without a
|
||||||
/// <c>ScadaLink.Communication</c>, so the gRPC server cannot reach back
|
/// project-reference cycle).
|
||||||
/// into AuditLog for its mapper. The shape mirrors
|
|
||||||
/// <c>AuditEventMapper.FromDto</c> in <c>ScadaLink.AuditLog.Telemetry</c>;
|
|
||||||
/// the two must evolve together.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// When <see cref="_auditIngestActor"/> is not yet wired (host startup
|
/// When <see cref="_auditIngestActor"/> is not yet wired (host startup
|
||||||
@@ -262,36 +257,10 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
return new IngestAck();
|
return new IngestAck();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inlined FromDto. Keep in sync with AuditEventMapper.FromDto in
|
|
||||||
// ScadaLink.AuditLog.Telemetry — there is no shared mapper because
|
|
||||||
// doing so would create a project-reference cycle (AuditLog → Communication).
|
|
||||||
var entities = new List<AuditEvent>(request.Events.Count);
|
var entities = new List<AuditEvent>(request.Events.Count);
|
||||||
foreach (var dto in request.Events)
|
foreach (var dto in request.Events)
|
||||||
{
|
{
|
||||||
entities.Add(new AuditEvent
|
entities.Add(AuditEventDtoMapper.FromDto(dto));
|
||||||
{
|
|
||||||
EventId = Guid.Parse(dto.EventId),
|
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
||||||
IngestedAtUtc = null,
|
|
||||||
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
|
||||||
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
|
||||||
CorrelationId = string.IsNullOrEmpty(dto.CorrelationId) ? null : Guid.Parse(dto.CorrelationId),
|
|
||||||
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
|
||||||
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
|
||||||
SourceScript = NullIfEmpty(dto.SourceScript),
|
|
||||||
Actor = NullIfEmpty(dto.Actor),
|
|
||||||
Target = NullIfEmpty(dto.Target),
|
|
||||||
Status = Enum.Parse<AuditStatus>(dto.Status),
|
|
||||||
HttpStatus = dto.HttpStatus,
|
|
||||||
DurationMs = dto.DurationMs,
|
|
||||||
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
|
|
||||||
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
|
|
||||||
RequestSummary = NullIfEmpty(dto.RequestSummary),
|
|
||||||
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
|
|
||||||
PayloadTruncated = dto.PayloadTruncated,
|
|
||||||
Extra = NullIfEmpty(dto.Extra),
|
|
||||||
ForwardState = null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd = new IngestAuditEventsCommand(entities);
|
var cmd = new IngestAuditEventsCommand(entities);
|
||||||
@@ -355,8 +324,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
var entries = new List<CachedTelemetryEntry>(request.Packets.Count);
|
var entries = new List<CachedTelemetryEntry>(request.Packets.Count);
|
||||||
foreach (var packet in request.Packets)
|
foreach (var packet in request.Packets)
|
||||||
{
|
{
|
||||||
var auditEvent = MapAuditEventFromDto(packet.AuditEvent);
|
var auditEvent = AuditEventDtoMapper.FromDto(packet.AuditEvent);
|
||||||
var siteCall = MapSiteCallFromDto(packet.Operational);
|
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
|
||||||
entries.Add(new CachedTelemetryEntry(auditEvent, siteCall));
|
entries.Add(new CachedTelemetryEntry(auditEvent, siteCall));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +419,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
};
|
};
|
||||||
foreach (var evt in events)
|
foreach (var evt in events)
|
||||||
{
|
{
|
||||||
response.Events.Add(AuditEventToDto(evt));
|
response.Events.Add(AuditEventDtoMapper.ToDto(evt));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flip to Reconciled AFTER projecting the response so a fault below the
|
// Flip to Reconciled AFTER projecting the response so a fault below the
|
||||||
@@ -481,110 +450,6 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Inlined audit-event entity→DTO translation. Keep in sync with
|
|
||||||
/// <c>AuditEventMapper.ToDto</c> in <c>ScadaLink.AuditLog.Telemetry</c> —
|
|
||||||
/// the project-reference cycle (AuditLog → Communication) prevents calling
|
|
||||||
/// the AuditLog mapper directly. The shape mirrors the FromDto pair above.
|
|
||||||
/// </summary>
|
|
||||||
private static AuditEventDto AuditEventToDto(AuditEvent evt)
|
|
||||||
{
|
|
||||||
var dto = new AuditEventDto
|
|
||||||
{
|
|
||||||
EventId = evt.EventId.ToString(),
|
|
||||||
OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(EnsureUtc(evt.OccurredAtUtc)),
|
|
||||||
Channel = evt.Channel.ToString(),
|
|
||||||
Kind = evt.Kind.ToString(),
|
|
||||||
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
|
||||||
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
|
||||||
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
|
||||||
SourceScript = evt.SourceScript ?? string.Empty,
|
|
||||||
Actor = evt.Actor ?? string.Empty,
|
|
||||||
Target = evt.Target ?? string.Empty,
|
|
||||||
Status = evt.Status.ToString(),
|
|
||||||
ErrorMessage = evt.ErrorMessage ?? string.Empty,
|
|
||||||
ErrorDetail = evt.ErrorDetail ?? string.Empty,
|
|
||||||
RequestSummary = evt.RequestSummary ?? string.Empty,
|
|
||||||
ResponseSummary = evt.ResponseSummary ?? string.Empty,
|
|
||||||
PayloadTruncated = evt.PayloadTruncated,
|
|
||||||
Extra = evt.Extra ?? string.Empty,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (evt.HttpStatus.HasValue) dto.HttpStatus = evt.HttpStatus.Value;
|
|
||||||
if (evt.DurationMs.HasValue) dto.DurationMs = evt.DurationMs.Value;
|
|
||||||
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTime EnsureUtc(DateTime value) =>
|
|
||||||
value.Kind == DateTimeKind.Utc
|
|
||||||
? value
|
|
||||||
: DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);
|
|
||||||
|
|
||||||
private static string? NullIfEmpty(string? value) =>
|
|
||||||
string.IsNullOrEmpty(value) ? null : value;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Inlined audit-event DTO→entity translation, kept in sync with the
|
|
||||||
/// <see cref="IngestAuditEvents"/> handler above. Extracted to a private
|
|
||||||
/// helper so the M3 dual-write RPC can reuse it without duplicating yet
|
|
||||||
/// another copy. The shape still mirrors
|
|
||||||
/// <c>AuditEventMapper.FromDto</c> in <c>ScadaLink.AuditLog.Telemetry</c>;
|
|
||||||
/// the two must evolve together (the project-reference cycle that
|
|
||||||
/// prevents calling the AuditLog mapper directly is documented on
|
|
||||||
/// <see cref="IngestAuditEvents"/>).
|
|
||||||
/// </summary>
|
|
||||||
private static AuditEvent MapAuditEventFromDto(AuditEventDto dto) =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
EventId = Guid.Parse(dto.EventId),
|
|
||||||
OccurredAtUtc = DateTime.SpecifyKind(dto.OccurredAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
||||||
IngestedAtUtc = null,
|
|
||||||
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
|
||||||
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
|
||||||
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
|
||||||
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
|
||||||
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
|
||||||
SourceScript = NullIfEmpty(dto.SourceScript),
|
|
||||||
Actor = NullIfEmpty(dto.Actor),
|
|
||||||
Target = NullIfEmpty(dto.Target),
|
|
||||||
Status = Enum.Parse<AuditStatus>(dto.Status),
|
|
||||||
HttpStatus = dto.HttpStatus,
|
|
||||||
DurationMs = dto.DurationMs,
|
|
||||||
ErrorMessage = NullIfEmpty(dto.ErrorMessage),
|
|
||||||
ErrorDetail = NullIfEmpty(dto.ErrorDetail),
|
|
||||||
RequestSummary = NullIfEmpty(dto.RequestSummary),
|
|
||||||
ResponseSummary = NullIfEmpty(dto.ResponseSummary),
|
|
||||||
PayloadTruncated = dto.PayloadTruncated,
|
|
||||||
Extra = NullIfEmpty(dto.Extra),
|
|
||||||
ForwardState = null,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Translates a <see cref="SiteCallOperationalDto"/> into the persistence
|
|
||||||
/// entity. <see cref="SiteCall.IngestedAtUtc"/> is stamped here as a
|
|
||||||
/// placeholder; the central ingest actor overwrites it inside the
|
|
||||||
/// dual-write transaction so the AuditLog and SiteCalls rows share one
|
|
||||||
/// instant.
|
|
||||||
/// </summary>
|
|
||||||
private static SiteCall MapSiteCallFromDto(SiteCallOperationalDto dto) => new()
|
|
||||||
{
|
|
||||||
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
|
|
||||||
Channel = dto.Channel,
|
|
||||||
Target = dto.Target,
|
|
||||||
SourceSite = dto.SourceSite,
|
|
||||||
Status = dto.Status,
|
|
||||||
RetryCount = dto.RetryCount,
|
|
||||||
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
|
|
||||||
HttpStatus = dto.HttpStatus,
|
|
||||||
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
||||||
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
||||||
TerminalAtUtc = dto.TerminalAtUtc is null
|
|
||||||
? null
|
|
||||||
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
||||||
IngestedAtUtc = DateTime.UtcNow, // overwritten by AuditLogIngestActor
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tracks a single active stream so cleanup only removes its own entry.
|
/// Tracks a single active stream so cleanup only removes its own entry.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -116,25 +116,28 @@ VALUES
|
|||||||
|
|
||||||
var query = _context.Set<AuditEvent>().AsNoTracking();
|
var query = _context.Set<AuditEvent>().AsNoTracking();
|
||||||
|
|
||||||
if (filter.Channel is { } channel)
|
// Multi-value dimensions: a null OR empty list means "no constraint"
|
||||||
|
// (the { Count: > 0 } guard prevents an empty list collapsing to a
|
||||||
|
// WHERE 1=0). A non-empty list translates to a SQL IN (…) via EF Core's
|
||||||
|
// IReadOnlyList<T>.Contains support — server-side, no client-eval.
|
||||||
|
if (filter.Channels is { Count: > 0 } channels)
|
||||||
{
|
{
|
||||||
query = query.Where(e => e.Channel == channel);
|
query = query.Where(e => channels.Contains(e.Channel));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.Kind is { } kind)
|
if (filter.Kinds is { Count: > 0 } kinds)
|
||||||
{
|
{
|
||||||
query = query.Where(e => e.Kind == kind);
|
query = query.Where(e => kinds.Contains(e.Kind));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.Status is { } status)
|
if (filter.Statuses is { Count: > 0 } statuses)
|
||||||
{
|
{
|
||||||
query = query.Where(e => e.Status == status);
|
query = query.Where(e => statuses.Contains(e.Status));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filter.SourceSiteId))
|
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
|
||||||
{
|
{
|
||||||
var siteId = filter.SourceSiteId;
|
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
|
||||||
query = query.Where(e => e.SourceSiteId == siteId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filter.Target))
|
if (!string.IsNullOrEmpty(filter.Target))
|
||||||
|
|||||||
@@ -164,7 +164,13 @@ WHERE TrackedOperationId = {idText}
|
|||||||
|
|
||||||
var fromUtc = filter.FromUtc;
|
var fromUtc = filter.FromUtc;
|
||||||
var toUtc = filter.ToUtc;
|
var toUtc = filter.ToUtc;
|
||||||
|
var stuckCutoff = filter.StuckCutoffUtc;
|
||||||
|
|
||||||
|
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
|
||||||
|
// is pushed into SQL here — both columns are plain (no value converter)
|
||||||
|
// and compose with the keyset cursor, so a StuckOnly page is honest:
|
||||||
|
// never under-filled with a non-null next cursor. Mirrors how
|
||||||
|
// NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff.
|
||||||
FormattableString sql = $@"
|
FormattableString sql = $@"
|
||||||
SELECT TOP ({paging.PageSize})
|
SELECT TOP ({paging.PageSize})
|
||||||
TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount,
|
TrackedOperationId, Channel, Target, SourceSite, Status, RetryCount,
|
||||||
@@ -176,6 +182,7 @@ WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
|
|||||||
AND ({filter.Target} IS NULL OR Target = {filter.Target})
|
AND ({filter.Target} IS NULL OR Target = {filter.Target})
|
||||||
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
|
AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
|
||||||
AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
|
AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
|
||||||
|
AND ({stuckCutoff} IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < {stuckCutoff}))
|
||||||
AND ({(hasCursor ? 1 : 0)} = 0
|
AND ({(hasCursor ? 1 : 0)} = 0
|
||||||
OR CreatedAtUtc < {afterCreated}
|
OR CreatedAtUtc < {afterCreated}
|
||||||
OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
|
OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
|
||||||
@@ -201,6 +208,141 @@ ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";
|
|||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal status string literals for the interval-throughput KPIs. The
|
||||||
|
// Status column is a plain varchar (no value converter), so these compare
|
||||||
|
// directly in translated SQL.
|
||||||
|
//
|
||||||
|
// NOTE on the "buffered/non-terminal" definition: the SiteCalls operational
|
||||||
|
// mirror stores AuditStatus-derived strings (Attempted/Delivered/Parked/
|
||||||
|
// Failed/...), NOT the tracking-lifecycle Pending/Retrying names the spec's
|
||||||
|
// KPI section uses. There is therefore no Status string that means
|
||||||
|
// "buffered". The schema-honest predicate for "non-terminal / buffered" is
|
||||||
|
// TerminalAtUtc IS NULL — consistent with PurgeTerminalAsync's terminal
|
||||||
|
// predicate and with the SiteCall entity's own contract ("TerminalAtUtc ...
|
||||||
|
// null while still active"). All buffered / stuck / oldest-pending counts
|
||||||
|
// below key off TerminalAtUtc, not Status.
|
||||||
|
private const string StatusParked = "Parked";
|
||||||
|
private const string StatusDelivered = "Delivered";
|
||||||
|
private const string StatusFailed = "Failed";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the global KPI snapshot with five server-side aggregate queries
|
||||||
|
/// against <c>dbo.SiteCalls</c>. No rows are materialised — every count is a
|
||||||
|
/// translated <c>COUNT</c> and the oldest-pending age is a translated
|
||||||
|
/// <c>MIN(CreatedAtUtc)</c>. The <c>Status</c> and <c>CreatedAtUtc</c>/<c>TerminalAtUtc</c>
|
||||||
|
/// columns have no value converter, so the aggregates translate cleanly to
|
||||||
|
/// SQL Server (unlike the NotificationOutbox's <c>DateTimeOffset</c>-converted
|
||||||
|
/// column, which forces an order-and-take). "Buffered" / "stuck" key off
|
||||||
|
/// <c>TerminalAtUtc IS NULL</c> — see the field comments above.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var bufferedCount = await _context.SiteCalls
|
||||||
|
.CountAsync(s => s.TerminalAtUtc == null, ct);
|
||||||
|
|
||||||
|
var parkedCount = await _context.SiteCalls
|
||||||
|
.CountAsync(s => s.Status == StatusParked, ct);
|
||||||
|
|
||||||
|
var failedLastInterval = await _context.SiteCalls
|
||||||
|
.CountAsync(s => s.Status == StatusFailed
|
||||||
|
&& s.TerminalAtUtc != null
|
||||||
|
&& s.TerminalAtUtc >= intervalSince, ct);
|
||||||
|
|
||||||
|
var deliveredLastInterval = await _context.SiteCalls
|
||||||
|
.CountAsync(s => s.Status == StatusDelivered
|
||||||
|
&& s.TerminalAtUtc != null
|
||||||
|
&& s.TerminalAtUtc >= intervalSince, ct);
|
||||||
|
|
||||||
|
var stuckCount = await _context.SiteCalls
|
||||||
|
.CountAsync(s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
|
||||||
|
|
||||||
|
var nonTerminal = _context.SiteCalls.Where(s => s.TerminalAtUtc == null);
|
||||||
|
|
||||||
|
TimeSpan? oldestPendingAge = null;
|
||||||
|
if (await nonTerminal.AnyAsync(ct))
|
||||||
|
{
|
||||||
|
var oldestCreatedAt = await nonTerminal.MinAsync(s => s.CreatedAtUtc, ct);
|
||||||
|
oldestPendingAge = now - oldestCreatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SiteCallKpiSnapshot(
|
||||||
|
BufferedCount: bufferedCount,
|
||||||
|
ParkedCount: parkedCount,
|
||||||
|
FailedLastInterval: failedLastInterval,
|
||||||
|
DeliveredLastInterval: deliveredLastInterval,
|
||||||
|
OldestPendingAge: oldestPendingAge,
|
||||||
|
StuckCount: stuckCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the per-source-site KPI breakdown. The five counts are
|
||||||
|
/// <c>GROUP BY SourceSite</c> aggregates; the oldest-pending age is a
|
||||||
|
/// per-site <c>MIN(CreatedAtUtc)</c> over the (bounded) non-terminal set —
|
||||||
|
/// all run server-side. A site appears in the result only if it has at
|
||||||
|
/// least one row matched by one of the count queries. "Buffered" / "stuck"
|
||||||
|
/// key off <c>TerminalAtUtc IS NULL</c> — see <see cref="ComputeKpisAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var buffered = await CountBySiteAsync(s => s.TerminalAtUtc == null, ct);
|
||||||
|
|
||||||
|
var parked = await CountBySiteAsync(s => s.Status == StatusParked, ct);
|
||||||
|
|
||||||
|
var failed = await CountBySiteAsync(
|
||||||
|
s => s.Status == StatusFailed
|
||||||
|
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
|
||||||
|
|
||||||
|
var delivered = await CountBySiteAsync(
|
||||||
|
s => s.Status == StatusDelivered
|
||||||
|
&& s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);
|
||||||
|
|
||||||
|
var stuck = await CountBySiteAsync(
|
||||||
|
s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);
|
||||||
|
|
||||||
|
// Oldest non-terminal CreatedAtUtc per site — a server-side GROUP BY MIN.
|
||||||
|
var oldest = (await _context.SiteCalls
|
||||||
|
.Where(s => s.TerminalAtUtc == null)
|
||||||
|
.GroupBy(s => s.SourceSite)
|
||||||
|
.Select(g => new { Site = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) })
|
||||||
|
.ToListAsync(ct))
|
||||||
|
.ToDictionary(x => x.Site, x => x.Oldest);
|
||||||
|
|
||||||
|
var siteIds = buffered.Keys
|
||||||
|
.Concat(parked.Keys).Concat(failed.Keys)
|
||||||
|
.Concat(delivered.Keys).Concat(stuck.Keys)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(s => s, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
return siteIds.Select(site => new SiteCallSiteKpiSnapshot(
|
||||||
|
SourceSite: site,
|
||||||
|
BufferedCount: buffered.GetValueOrDefault(site),
|
||||||
|
ParkedCount: parked.GetValueOrDefault(site),
|
||||||
|
FailedLastInterval: failed.GetValueOrDefault(site),
|
||||||
|
DeliveredLastInterval: delivered.GetValueOrDefault(site),
|
||||||
|
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
|
||||||
|
? now - createdAt
|
||||||
|
: null,
|
||||||
|
StuckCount: stuck.GetValueOrDefault(site))).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Counts <c>SiteCalls</c> rows matching <paramref name="predicate"/>, grouped by source site.</summary>
|
||||||
|
private async Task<Dictionary<string, int>> CountBySiteAsync(
|
||||||
|
System.Linq.Expressions.Expression<Func<SiteCall, bool>> predicate,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _context.SiteCalls
|
||||||
|
.Where(predicate)
|
||||||
|
.GroupBy(s => s.SourceSite)
|
||||||
|
.Select(g => new { Site = g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Site, x => x.Count, ct);
|
||||||
|
}
|
||||||
|
|
||||||
private static int GetRankOrThrow(string status)
|
private static int GetRankOrThrow(string status)
|
||||||
{
|
{
|
||||||
if (!StatusRank.TryGetValue(status, out var rank))
|
if (!StatusRank.TryGetValue(status, out var rank))
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ akka {{
|
|||||||
.WithSingletonName("audit-log-ingest"));
|
.WithSingletonName("audit-log-ingest"));
|
||||||
var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy");
|
var auditIngestProxy = _actorSystem.ActorOf(auditIngestProxyProps, "audit-log-ingest-proxy");
|
||||||
|
|
||||||
|
// Hand the audit-ingest proxy to the CentralCommunicationActor so audit
|
||||||
|
// ingest commands forwarded by sites over ClusterClient are routed to the
|
||||||
|
// singleton. Mirrors the RegisterNotificationOutbox wiring above.
|
||||||
|
centralCommActor.Tell(new RegisterAuditIngest(auditIngestProxy));
|
||||||
|
|
||||||
// Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
|
// Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
|
||||||
// so the IngestAuditEvents RPC routes incoming site batches to the singleton.
|
// so the IngestAuditEvents RPC routes incoming site batches to the singleton.
|
||||||
// The gRPC server is currently only registered on Site nodes; on a central
|
// The gRPC server is currently only registered on Site nodes; on a central
|
||||||
@@ -410,18 +415,23 @@ akka {{
|
|||||||
// and NotificationOutbox patterns. M3's dual-write transaction routes
|
// and NotificationOutbox patterns. M3's dual-write transaction routes
|
||||||
// SiteCalls upserts through AuditLogIngestActor's own scope-per-message
|
// SiteCalls upserts through AuditLogIngestActor's own scope-per-message
|
||||||
// ISiteCallAuditRepository resolution, so this singleton is not on the
|
// ISiteCallAuditRepository resolution, so this singleton is not on the
|
||||||
// M3 happy-path hot path; it exists so future direct-write callers
|
// M3 happy-path hot path; it exists so direct-write callers Ask through
|
||||||
// (reconciliation puller, central→site Retry/Discard relay, KPI
|
// a stable cluster proxy without further wiring. The central→site
|
||||||
// projector) Ask through a stable cluster proxy without further wiring.
|
// Retry/Discard relay now lives in this actor (see the
|
||||||
|
// RegisterCentralCommunication wiring below); the reconciliation puller
|
||||||
|
// is the remaining deferred direct-write caller.
|
||||||
// Like AuditLogIngestActor, the actor takes the root IServiceProvider
|
// Like AuditLogIngestActor, the actor takes the root IServiceProvider
|
||||||
// and creates a fresh scope per message because ISiteCallAuditRepository
|
// and creates a fresh scope per message because ISiteCallAuditRepository
|
||||||
// is a scoped EF Core service.
|
// is a scoped EF Core service.
|
||||||
var siteCallAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
var siteCallAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger<ScadaLink.SiteCallAudit.SiteCallAuditActor>();
|
.CreateLogger<ScadaLink.SiteCallAudit.SiteCallAuditActor>();
|
||||||
|
var siteCallAuditOptions = _serviceProvider
|
||||||
|
.GetRequiredService<IOptions<ScadaLink.SiteCallAudit.SiteCallAuditOptions>>().Value;
|
||||||
|
|
||||||
var siteCallAuditSingletonProps = ClusterSingletonManager.Props(
|
var siteCallAuditSingletonProps = ClusterSingletonManager.Props(
|
||||||
singletonProps: Props.Create(() => new ScadaLink.SiteCallAudit.SiteCallAuditActor(
|
singletonProps: Props.Create(() => new ScadaLink.SiteCallAudit.SiteCallAuditActor(
|
||||||
_serviceProvider,
|
_serviceProvider,
|
||||||
|
siteCallAuditOptions,
|
||||||
siteCallAuditLogger)),
|
siteCallAuditLogger)),
|
||||||
terminationMessage: PoisonPill.Instance,
|
terminationMessage: PoisonPill.Instance,
|
||||||
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
||||||
@@ -432,8 +442,23 @@ akka {{
|
|||||||
singletonManagerPath: "/user/site-call-audit-singleton",
|
singletonManagerPath: "/user/site-call-audit-singleton",
|
||||||
settings: ClusterSingletonProxySettings.Create(_actorSystem)
|
settings: ClusterSingletonProxySettings.Create(_actorSystem)
|
||||||
.WithSingletonName("site-call-audit"));
|
.WithSingletonName("site-call-audit"));
|
||||||
_actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy");
|
var siteCallAuditProxy = _actorSystem.ActorOf(siteCallAuditProxyProps, "site-call-audit-proxy");
|
||||||
_logger.LogInformation("SiteCallAuditActor singleton created");
|
|
||||||
|
// Hand the proxy to the CommunicationService so the Central UI can Ask
|
||||||
|
// the Site Call Audit actor directly (query, KPIs, detail) — mirrors the
|
||||||
|
// SetNotificationOutbox wiring above.
|
||||||
|
commService?.SetSiteCallAudit(siteCallAuditProxy);
|
||||||
|
|
||||||
|
// Task 5 (#22): hand the CentralCommunicationActor to the SiteCallAudit
|
||||||
|
// actor so it can relay operator Retry/Discard on parked cached calls to
|
||||||
|
// the owning site (over the per-site ClusterClient via SiteEnvelope).
|
||||||
|
// Mirrors the RegisterAuditIngest / RegisterNotificationOutbox wiring;
|
||||||
|
// the message is sent to the singleton proxy so it reaches whichever
|
||||||
|
// central node currently hosts the singleton.
|
||||||
|
siteCallAuditProxy.Tell(
|
||||||
|
new ScadaLink.SiteCallAudit.RegisterCentralCommunication(centralCommActor));
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SiteCallAuditActor singleton created and registered with CentralCommunicationActor");
|
||||||
|
|
||||||
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
|
_logger.LogInformation("Central actors registered. CentralCommunicationActor created.");
|
||||||
}
|
}
|
||||||
@@ -656,15 +681,26 @@ akka {{
|
|||||||
// Per Bundle E's brief: the SiteAuditTelemetryActor takes its
|
// Per Bundle E's brief: the SiteAuditTelemetryActor takes its
|
||||||
// collaborators through its constructor, so we resolve them from DI
|
// collaborators through its constructor, so we resolve them from DI
|
||||||
// and pass them in via Props.Create rather than relying on a future
|
// and pass them in via Props.Create rather than relying on a future
|
||||||
// FactoryProvider. This also lets the M6 follow-up swap the
|
// FactoryProvider. The real site→central client is constructed and
|
||||||
// NoOpSiteStreamAuditClient registration for the real gRPC client
|
// wired immediately below: a ClusterClientSiteAuditClient (ClusterClient
|
||||||
// without touching this site wiring.
|
// transport, not gRPC) replaces the DI-default NoOpSiteStreamAuditClient
|
||||||
|
// for site roles, without disturbing the rest of this wiring.
|
||||||
var siteAuditOptions = _serviceProvider
|
var siteAuditOptions = _serviceProvider
|
||||||
.GetRequiredService<IOptions<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>();
|
.GetRequiredService<IOptions<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>();
|
||||||
var siteAuditQueue = _serviceProvider
|
var siteAuditQueue = _serviceProvider
|
||||||
.GetRequiredService<ScadaLink.Commons.Interfaces.Services.ISiteAuditQueue>();
|
.GetRequiredService<ScadaLink.Commons.Interfaces.Services.ISiteAuditQueue>();
|
||||||
var siteAuditClient = _serviceProvider
|
// Audit Log (#23) Task 2 follow-up: the production site→central audit
|
||||||
.GetRequiredService<ScadaLink.AuditLog.Site.Telemetry.ISiteStreamAuditClient>();
|
// push uses the ClusterClient transport via the SiteCommunicationActor,
|
||||||
|
// not the DI-resolved NoOpSiteStreamAuditClient. The NoOp default stays
|
||||||
|
// correct for central/test composition roots (no SiteCommunicationActor);
|
||||||
|
// a site role wires the real ClusterClient-based client here so the
|
||||||
|
// SQLite Pending backlog actually drains to central. The forward Ask
|
||||||
|
// reuses NotificationForwardTimeout — the same site→central command
|
||||||
|
// forward bound notifications already use over this transport.
|
||||||
|
ScadaLink.AuditLog.Site.Telemetry.ISiteStreamAuditClient siteAuditClient =
|
||||||
|
new ScadaLink.AuditLog.Site.Telemetry.ClusterClientSiteAuditClient(
|
||||||
|
siteCommActor,
|
||||||
|
_communicationOptions.NotificationForwardTimeout);
|
||||||
var siteAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
var siteAuditLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor>();
|
.CreateLogger<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryActor>();
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<script src="/js/treeview-storage.js"></script>
|
<script src="/js/treeview-storage.js"></script>
|
||||||
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
|
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
|
||||||
|
<script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script>
|
||||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -367,32 +367,26 @@ public static class AuditEndpoints
|
|||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. Unknown
|
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
|
||||||
/// enum names / un-parseable Guids / dates are silently dropped (no 400) —
|
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
|
||||||
/// the same lax contract the CentralUI export endpoint uses.
|
/// multi-value: a repeated query param (<c>channel=A&channel=B</c>) yields
|
||||||
|
/// a multi-element filter list, while a single param yields a one-element
|
||||||
|
/// list. Unknown enum names / un-parseable Guids / dates are silently dropped
|
||||||
|
/// (no 400) — the same lax contract the CentralUI export endpoint uses; an
|
||||||
|
/// unparseable value within a repeated set is dropped, not the whole set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This endpoint reads the source-site filter from the <c>sourceSiteId</c>
|
||||||
|
/// query key, whereas the CentralUI export endpoint reads it as <c>site</c>.
|
||||||
|
/// The divergence is deliberate — each endpoint matches its own CLI / UI URL
|
||||||
|
/// builder — so do NOT "fix" the two to a single key name.
|
||||||
|
/// </remarks>
|
||||||
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||||
{
|
{
|
||||||
AuditChannel? channel = null;
|
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
|
||||||
if (query.TryGetValue("channel", out var channelValues)
|
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
|
||||||
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
|
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
|
||||||
{
|
var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]);
|
||||||
channel = parsedChannel;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuditKind? kind = null;
|
|
||||||
if (query.TryGetValue("kind", out var kindValues)
|
|
||||||
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
|
|
||||||
{
|
|
||||||
kind = parsedKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
AuditStatus? status = null;
|
|
||||||
if (query.TryGetValue("status", out var statusValues)
|
|
||||||
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
|
|
||||||
{
|
|
||||||
status = parsedStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
Guid? correlationId = null;
|
Guid? correlationId = null;
|
||||||
if (query.TryGetValue("correlationId", out var corrValues)
|
if (query.TryGetValue("correlationId", out var corrValues)
|
||||||
@@ -402,10 +396,10 @@ public static class AuditEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new AuditLogQueryFilter(
|
return new AuditLogQueryFilter(
|
||||||
Channel: channel,
|
Channels: channels,
|
||||||
Kind: kind,
|
Kinds: kinds,
|
||||||
Status: status,
|
Statuses: statuses,
|
||||||
SourceSiteId: TrimToNullable(query, "sourceSiteId"),
|
SourceSiteIds: sourceSiteIds,
|
||||||
Target: TrimToNullable(query, "target"),
|
Target: TrimToNullable(query, "target"),
|
||||||
Actor: TrimToNullable(query, "actor"),
|
Actor: TrimToNullable(query, "actor"),
|
||||||
CorrelationId: correlationId,
|
CorrelationId: correlationId,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
|
<!-- BindConfiguration extension for the SiteCallAuditOptions binding. -->
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -22,6 +24,11 @@
|
|||||||
project reference is documented here so the actor's scope-per-message
|
project reference is documented here so the actor's scope-per-message
|
||||||
GetRequiredService<ISiteCallAuditRepository>() compiles. -->
|
GetRequiredService<ISiteCallAuditRepository>() compiles. -->
|
||||||
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||||
|
<!-- Task 5 (#22): the central→site Retry/Discard relay routes RetryParkedOperation /
|
||||||
|
DiscardParkedOperation to the owning site via SiteEnvelope + CentralCommunicationActor,
|
||||||
|
the same transport every other central→site command uses. SiteEnvelope is defined
|
||||||
|
in ScadaLink.Communication (no cycle: Communication does not reference SiteCallAudit). -->
|
||||||
|
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -7,33 +7,34 @@ namespace ScadaLink.SiteCallAudit;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// M3 Bundle C ships the ingest-only minimum surface (the actor itself); the
|
/// Binds <see cref="SiteCallAuditOptions"/> (stuck-call detection + KPI
|
||||||
/// full DI surface — reconciliation puller, KPI projector, central→site
|
/// windowing for the read-side query/KPI handlers). The reconciliation puller
|
||||||
/// Retry/Discard relay, options + validators — is deferred to a follow-up.
|
/// and central→site Retry/Discard relay are still deferred to later follow-ups.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The repository (<c>ISiteCallAuditRepository</c>) is registered by
|
/// The repository (<c>ISiteCallAuditRepository</c>) is registered by
|
||||||
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
|
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
|
||||||
/// so callers (the Host on the central node) must also call that. The actor's
|
/// so callers (the Host on the central node) must also call that. The actor's
|
||||||
/// <c>Props</c> are wired up in Host registration (Bundle F); this extension
|
/// <c>Props</c> are wired up in Host registration.
|
||||||
/// is currently a no-op placeholder kept for symmetry with the AuditLog and
|
|
||||||
/// NotificationOutbox composition roots — adding it now means consumers can
|
|
||||||
/// reference the method without re-touching the Host project later.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
|
/// <summary>Configuration section bound to <see cref="SiteCallAuditOptions"/>.</summary>
|
||||||
|
public const string OptionsSection = "ScadaLink:SiteCallAudit";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers Site Call Audit (#22) services. Currently a no-op
|
/// Registers Site Call Audit (#22) services: the <see cref="SiteCallAuditOptions"/>
|
||||||
/// placeholder — Bundle F will populate this with the actor's Props
|
/// binding consumed by the actor's read-side KPI/query handlers. The actor's
|
||||||
/// factory + options bindings. The method is exposed now so the Host
|
/// <c>Props</c> are still constructed inline in Host wiring.
|
||||||
/// wiring call already exists at the API boundary.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddSiteCallAudit(this IServiceCollection services)
|
public static IServiceCollection AddSiteCallAudit(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(services);
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
// Actor props are constructed in Host wiring (Bundle F). This
|
|
||||||
// extension is a placeholder for future config + DI.
|
services.AddOptions<SiteCallAuditOptions>()
|
||||||
|
.BindConfiguration(OptionsSection);
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
using ScadaLink.Commons.Messages.Audit;
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
using ScadaLink.Commons.Messages.RemoteQuery;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Communication;
|
||||||
|
|
||||||
namespace ScadaLink.SiteCallAudit;
|
namespace ScadaLink.SiteCallAudit;
|
||||||
|
|
||||||
@@ -18,10 +23,10 @@ namespace ScadaLink.SiteCallAudit;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// M3 ships the minimum surface: ingest only. Reconciliation, KPIs, and
|
/// Query, detail and KPIs (Task 4) and the central→site Retry/Discard relay
|
||||||
/// central→site Retry/Discard relay are deferred (per CLAUDE.md scope
|
/// (Task 5 — the relay handlers live in this actor) are implemented; only
|
||||||
/// discipline — Site Call Audit's KPIs and the Retry/Discard relay land in a
|
/// reconciliation remains deferred (per CLAUDE.md scope discipline — it lands
|
||||||
/// follow-up).
|
/// in a later follow-up).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
|
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
|
||||||
@@ -42,26 +47,47 @@ namespace ScadaLink.SiteCallAudit;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class SiteCallAuditActor : ReceiveActor
|
public class SiteCallAuditActor : ReceiveActor
|
||||||
{
|
{
|
||||||
|
/// <summary>Maximum page size honoured by a <see cref="SiteCallQueryRequest"/>.</summary>
|
||||||
|
private const int MaxPageSize = 200;
|
||||||
|
|
||||||
private readonly IServiceProvider? _serviceProvider;
|
private readonly IServiceProvider? _serviceProvider;
|
||||||
private readonly ISiteCallAuditRepository? _injectedRepository;
|
private readonly ISiteCallAuditRepository? _injectedRepository;
|
||||||
|
private readonly SiteCallAuditOptions _options;
|
||||||
private readonly ILogger<SiteCallAuditActor> _logger;
|
private readonly ILogger<SiteCallAuditActor> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 5 (#22): the central→site command transport — the
|
||||||
|
/// <c>CentralCommunicationActor</c>, which owns the per-site
|
||||||
|
/// <c>ClusterClient</c> map and routes a <see cref="SiteEnvelope"/> to the
|
||||||
|
/// owning site. Set via <see cref="RegisterCentralCommunication"/> by the
|
||||||
|
/// Host after both actors exist (this actor is a cluster singleton; the
|
||||||
|
/// transport actor is created separately). Null until registration
|
||||||
|
/// completes — a relay arriving before then is answered with a
|
||||||
|
/// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome, because there
|
||||||
|
/// is genuinely no route to any site yet.
|
||||||
|
/// </summary>
|
||||||
|
private IActorRef? _centralCommunication;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test-mode constructor — injects a concrete repository instance whose
|
/// Test-mode constructor — injects a concrete repository instance whose
|
||||||
/// lifetime exceeds the test, so the actor reuses the same instance
|
/// lifetime exceeds the test, so the actor reuses the same instance
|
||||||
/// across every message. Used by Bundle C's MSSQL-backed TestKit fixture.
|
/// across every message. Used by Bundle C's MSSQL-backed TestKit fixture.
|
||||||
|
/// An optional <paramref name="options"/> lets a test pin the stuck/KPI
|
||||||
|
/// windows; when omitted the production defaults apply.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SiteCallAuditActor(
|
public SiteCallAuditActor(
|
||||||
ISiteCallAuditRepository repository,
|
ISiteCallAuditRepository repository,
|
||||||
ILogger<SiteCallAuditActor> logger)
|
ILogger<SiteCallAuditActor> logger,
|
||||||
|
SiteCallAuditOptions? options = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(repository);
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
ArgumentNullException.ThrowIfNull(logger);
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
|
||||||
_injectedRepository = repository;
|
_injectedRepository = repository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_options = options ?? new SiteCallAuditOptions();
|
||||||
|
|
||||||
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
RegisterHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -73,15 +99,42 @@ public class SiteCallAuditActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public SiteCallAuditActor(
|
public SiteCallAuditActor(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
|
SiteCallAuditOptions options,
|
||||||
ILogger<SiteCallAuditActor> logger)
|
ILogger<SiteCallAuditActor> logger)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(serviceProvider);
|
ArgumentNullException.ThrowIfNull(serviceProvider);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ArgumentNullException.ThrowIfNull(logger);
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
|
_options = options;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
|
RegisterHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires up the message handlers shared by both constructors: the M3
|
||||||
|
/// ingest path plus the Task 4 read-side (query, detail, global + per-site
|
||||||
|
/// KPI). All read handlers reply to an Ask, so they capture <c>Sender</c>
|
||||||
|
/// before the first await and <c>PipeTo</c> the result back.
|
||||||
|
/// </summary>
|
||||||
|
private void RegisterHandlers()
|
||||||
|
{
|
||||||
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
|
||||||
|
Receive<SiteCallQueryRequest>(HandleQuery);
|
||||||
|
Receive<SiteCallDetailRequest>(HandleDetail);
|
||||||
|
Receive<SiteCallKpiRequest>(HandleKpi);
|
||||||
|
Receive<PerSiteSiteCallKpiRequest>(HandlePerSiteKpi);
|
||||||
|
|
||||||
|
// Task 5 (#22): central→site Retry/Discard relay for parked cached calls.
|
||||||
|
Receive<RegisterCentralCommunication>(msg =>
|
||||||
|
{
|
||||||
|
_centralCommunication = msg.CentralCommunication;
|
||||||
|
_logger.LogInformation("SiteCallAudit registered central→site communication transport");
|
||||||
|
});
|
||||||
|
Receive<RetrySiteCallRequest>(HandleRetrySiteCall);
|
||||||
|
Receive<DiscardSiteCallRequest>(HandleDiscardSiteCall);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -137,4 +190,486 @@ public class SiteCallAuditActor : ReceiveActor
|
|||||||
scope?.Dispose();
|
scope?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Task 4: read-side (query / detail / KPI) ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a paginated, filtered query over the <c>SiteCalls</c> table.
|
||||||
|
/// Builds a <see cref="SiteCallQueryFilter"/> + <see cref="SiteCallPaging"/>
|
||||||
|
/// keyset cursor from the request, runs the query on a scoped repository,
|
||||||
|
/// and pipes the mapped response back to the captured sender. A repository
|
||||||
|
/// fault yields a failure response with an empty list.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleQuery(SiteCallQueryRequest request)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
QueryAsync(request, now).PipeTo(
|
||||||
|
sender,
|
||||||
|
success: response => response,
|
||||||
|
failure: ex => new SiteCallQueryResponse(
|
||||||
|
request.CorrelationId,
|
||||||
|
Success: false,
|
||||||
|
ErrorMessage: ex.GetBaseException().Message,
|
||||||
|
SiteCalls: Array.Empty<SiteCallSummary>(),
|
||||||
|
NextAfterCreatedAtUtc: null,
|
||||||
|
NextAfterId: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SiteCallQueryResponse> QueryAsync(SiteCallQueryRequest request, DateTime now)
|
||||||
|
{
|
||||||
|
var stuckCutoff = now - _options.StuckAgeThreshold;
|
||||||
|
|
||||||
|
var filter = new SiteCallQueryFilter(
|
||||||
|
Channel: NullIfBlank(request.ChannelFilter),
|
||||||
|
SourceSite: NullIfBlank(request.SourceSiteFilter),
|
||||||
|
Status: NullIfBlank(request.StatusFilter),
|
||||||
|
Target: NullIfBlank(request.TargetKeyword),
|
||||||
|
FromUtc: request.FromUtc,
|
||||||
|
ToUtc: request.ToUtc,
|
||||||
|
// StuckOnly is pushed into the repository SQL via StuckCutoffUtc —
|
||||||
|
// TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the
|
||||||
|
// keyset cursor, so the page is always honest (full pages, no empty
|
||||||
|
// pages with a non-null next cursor).
|
||||||
|
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null);
|
||||||
|
|
||||||
|
var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
|
||||||
|
var paging = new SiteCallPaging(
|
||||||
|
PageSize: pageSize,
|
||||||
|
AfterCreatedAtUtc: request.AfterCreatedAtUtc,
|
||||||
|
AfterId: request.AfterId is { } id ? new TrackedOperationId(id) : null);
|
||||||
|
|
||||||
|
var (scope, repository) = ResolveRepository();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rows = await repository.QueryAsync(filter, paging).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var summaries = rows
|
||||||
|
.Select(row => ToSummary(row, stuckCutoff))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// The next-page cursor is the last row of the materialised page.
|
||||||
|
var cursorRow = rows.Count > 0 ? rows[^1] : null;
|
||||||
|
|
||||||
|
return new SiteCallQueryResponse(
|
||||||
|
request.CorrelationId,
|
||||||
|
Success: true,
|
||||||
|
ErrorMessage: null,
|
||||||
|
SiteCalls: summaries,
|
||||||
|
NextAfterCreatedAtUtc: cursorRow?.CreatedAtUtc,
|
||||||
|
NextAfterId: cursorRow?.TrackedOperationId.Value);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
scope?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a full-detail query for a single cached call — backs the report
|
||||||
|
/// detail modal. A missing row yields <c>Success=false</c> with a "not
|
||||||
|
/// found" message; a repository fault yields <c>Success=false</c> with the
|
||||||
|
/// fault message.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleDetail(SiteCallDetailRequest request)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
|
||||||
|
DetailAsync(request).PipeTo(
|
||||||
|
sender,
|
||||||
|
success: response => response,
|
||||||
|
failure: ex => new SiteCallDetailResponse(
|
||||||
|
request.CorrelationId,
|
||||||
|
Success: false,
|
||||||
|
ErrorMessage: ex.GetBaseException().Message,
|
||||||
|
Detail: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SiteCallDetailResponse> DetailAsync(SiteCallDetailRequest request)
|
||||||
|
{
|
||||||
|
var (scope, repository) = ResolveRepository();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var row = await repository
|
||||||
|
.GetAsync(new TrackedOperationId(request.TrackedOperationId))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return new SiteCallDetailResponse(
|
||||||
|
request.CorrelationId,
|
||||||
|
Success: false,
|
||||||
|
ErrorMessage: "site call not found",
|
||||||
|
Detail: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SiteCallDetailResponse(
|
||||||
|
request.CorrelationId,
|
||||||
|
Success: true,
|
||||||
|
ErrorMessage: null,
|
||||||
|
Detail: ToDetail(row));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
scope?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a global KPI snapshot request, deriving the stuck cutoff from
|
||||||
|
/// <see cref="SiteCallAuditOptions.StuckAgeThreshold"/> and the
|
||||||
|
/// failed/delivered interval bound from <see cref="SiteCallAuditOptions.KpiInterval"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleKpi(SiteCallKpiRequest request)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var stuckCutoff = now - _options.StuckAgeThreshold;
|
||||||
|
var intervalSince = now - _options.KpiInterval;
|
||||||
|
|
||||||
|
KpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
|
||||||
|
sender,
|
||||||
|
success: response => response,
|
||||||
|
failure: ex => new SiteCallKpiResponse(
|
||||||
|
request.CorrelationId,
|
||||||
|
Success: false,
|
||||||
|
ErrorMessage: ex.GetBaseException().Message,
|
||||||
|
BufferedCount: 0,
|
||||||
|
ParkedCount: 0,
|
||||||
|
FailedLastInterval: 0,
|
||||||
|
DeliveredLastInterval: 0,
|
||||||
|
OldestPendingAge: null,
|
||||||
|
StuckCount: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SiteCallKpiResponse> KpiAsync(
|
||||||
|
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
|
||||||
|
{
|
||||||
|
var (scope, repository) = ResolveRepository();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = await repository
|
||||||
|
.ComputeKpisAsync(stuckCutoff, intervalSince)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new SiteCallKpiResponse(
|
||||||
|
correlationId,
|
||||||
|
Success: true,
|
||||||
|
ErrorMessage: null,
|
||||||
|
snapshot.BufferedCount,
|
||||||
|
snapshot.ParkedCount,
|
||||||
|
snapshot.FailedLastInterval,
|
||||||
|
snapshot.DeliveredLastInterval,
|
||||||
|
snapshot.OldestPendingAge,
|
||||||
|
snapshot.StuckCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
scope?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a per-source-site KPI request, using the same stuck cutoff and
|
||||||
|
/// interval bound as <see cref="HandleKpi"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void HandlePerSiteKpi(PerSiteSiteCallKpiRequest request)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var stuckCutoff = now - _options.StuckAgeThreshold;
|
||||||
|
var intervalSince = now - _options.KpiInterval;
|
||||||
|
|
||||||
|
PerSiteKpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
|
||||||
|
sender,
|
||||||
|
success: response => response,
|
||||||
|
failure: ex => new PerSiteSiteCallKpiResponse(
|
||||||
|
request.CorrelationId,
|
||||||
|
Success: false,
|
||||||
|
ErrorMessage: ex.GetBaseException().Message,
|
||||||
|
Sites: Array.Empty<SiteCallSiteKpiSnapshot>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PerSiteSiteCallKpiResponse> PerSiteKpiAsync(
|
||||||
|
string correlationId, DateTime stuckCutoff, DateTime intervalSince)
|
||||||
|
{
|
||||||
|
var (scope, repository) = ResolveRepository();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sites = await repository
|
||||||
|
.ComputePerSiteKpisAsync(stuckCutoff, intervalSince)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new PerSiteSiteCallKpiResponse(
|
||||||
|
correlationId, Success: true, ErrorMessage: null, sites);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
scope?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task 5: central→site Retry/Discard relay ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relays an operator Retry of a parked cached call to its owning site. The
|
||||||
|
/// site is the source of truth — this handler NEVER writes the central
|
||||||
|
/// <c>SiteCalls</c> mirror row. It wraps a <see cref="RetryParkedOperation"/>
|
||||||
|
/// in a <see cref="SiteEnvelope"/> addressed to <c>SourceSite</c>, Asks the
|
||||||
|
/// <c>CentralCommunicationActor</c> (which routes it over the per-site
|
||||||
|
/// <c>ClusterClient</c>), and maps the site's
|
||||||
|
/// <see cref="ParkedOperationActionAck"/> — or an Ask timeout — onto a
|
||||||
|
/// <see cref="RetrySiteCallResponse"/>. A timeout / no-route is reported as
|
||||||
|
/// the distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome,
|
||||||
|
/// not a generic failure, so the Central UI can tell "site offline" from
|
||||||
|
/// "operation failed".
|
||||||
|
/// </summary>
|
||||||
|
private void HandleRetrySiteCall(RetrySiteCallRequest request)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
|
||||||
|
if (_centralCommunication is null)
|
||||||
|
{
|
||||||
|
// No transport registered yet — there is genuinely no route to any
|
||||||
|
// site, so the only honest answer is unreachable.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"RetrySiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
|
||||||
|
+ "central→site transport was registered; reporting site unreachable",
|
||||||
|
request.TrackedOperationId, request.SourceSite);
|
||||||
|
sender.Tell(UnreachableRetry(request.CorrelationId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relay = new RetryParkedOperation(
|
||||||
|
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
|
||||||
|
var envelope = new SiteEnvelope(request.SourceSite, relay);
|
||||||
|
|
||||||
|
_centralCommunication.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
|
||||||
|
.PipeTo(
|
||||||
|
sender,
|
||||||
|
success: ack => MapRetryResponse(request.CorrelationId, ack),
|
||||||
|
failure: ex => MapRetryFailure(request.CorrelationId, request.SourceSite, ex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relays an operator Discard of a parked cached call to its owning site.
|
||||||
|
/// Mirrors <see cref="HandleRetrySiteCall"/> — see that method for the
|
||||||
|
/// source-of-truth and site-unreachable rationale.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleDiscardSiteCall(DiscardSiteCallRequest request)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
|
||||||
|
if (_centralCommunication is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"DiscardSiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
|
||||||
|
+ "central→site transport was registered; reporting site unreachable",
|
||||||
|
request.TrackedOperationId, request.SourceSite);
|
||||||
|
sender.Tell(UnreachableDiscard(request.CorrelationId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relay = new DiscardParkedOperation(
|
||||||
|
request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
|
||||||
|
var envelope = new SiteEnvelope(request.SourceSite, relay);
|
||||||
|
|
||||||
|
_centralCommunication.Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
|
||||||
|
.PipeTo(
|
||||||
|
sender,
|
||||||
|
success: ack => MapDiscardResponse(request.CorrelationId, ack),
|
||||||
|
failure: ex => MapDiscardFailure(request.CorrelationId, request.SourceSite, ex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the site's <see cref="ParkedOperationActionAck"/> for a Retry onto a
|
||||||
|
/// <see cref="RetrySiteCallResponse"/>: an applied action is
|
||||||
|
/// <see cref="SiteCallRelayOutcome.Applied"/>; a clean no-op
|
||||||
|
/// (<c>Applied=false</c>, no error) is <see cref="SiteCallRelayOutcome.NotParked"/>;
|
||||||
|
/// an ack carrying an error is <see cref="SiteCallRelayOutcome.OperationFailed"/>
|
||||||
|
/// — in every case the site WAS reached.
|
||||||
|
/// </summary>
|
||||||
|
private static RetrySiteCallResponse MapRetryResponse(string correlationId, ParkedOperationActionAck ack)
|
||||||
|
{
|
||||||
|
var outcome = ClassifyAck(ack);
|
||||||
|
return new RetrySiteCallResponse(
|
||||||
|
correlationId,
|
||||||
|
outcome,
|
||||||
|
Success: outcome == SiteCallRelayOutcome.Applied,
|
||||||
|
SiteReachable: true,
|
||||||
|
ErrorMessage: AckErrorMessage(outcome, ack));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DiscardSiteCallResponse MapDiscardResponse(string correlationId, ParkedOperationActionAck ack)
|
||||||
|
{
|
||||||
|
var outcome = ClassifyAck(ack);
|
||||||
|
return new DiscardSiteCallResponse(
|
||||||
|
correlationId,
|
||||||
|
outcome,
|
||||||
|
Success: outcome == SiteCallRelayOutcome.Applied,
|
||||||
|
SiteReachable: true,
|
||||||
|
ErrorMessage: AckErrorMessage(outcome, ack));
|
||||||
|
}
|
||||||
|
|
||||||
|
private RetrySiteCallResponse MapRetryFailure(string correlationId, string sourceSite, Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Retry relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
|
||||||
|
return UnreachableRetry(correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiscardSiteCallResponse MapDiscardFailure(string correlationId, string sourceSite, Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Discard relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
|
||||||
|
return UnreachableDiscard(correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies a site ack: <c>Applied=true</c> → applied; <c>Applied=false</c>
|
||||||
|
/// with no error → the site definitively had nothing parked; <c>Applied=false</c>
|
||||||
|
/// with an error → the site could not apply the action.
|
||||||
|
/// </summary>
|
||||||
|
private static SiteCallRelayOutcome ClassifyAck(ParkedOperationActionAck ack)
|
||||||
|
{
|
||||||
|
if (ack.Applied)
|
||||||
|
{
|
||||||
|
return SiteCallRelayOutcome.Applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ack.ErrorMessage is null
|
||||||
|
? SiteCallRelayOutcome.NotParked
|
||||||
|
: SiteCallRelayOutcome.OperationFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? AckErrorMessage(SiteCallRelayOutcome outcome, ParkedOperationActionAck ack)
|
||||||
|
{
|
||||||
|
return outcome switch
|
||||||
|
{
|
||||||
|
SiteCallRelayOutcome.Applied => null,
|
||||||
|
SiteCallRelayOutcome.NotParked =>
|
||||||
|
"The operation is no longer parked at the site (already delivered, discarded, or retrying).",
|
||||||
|
SiteCallRelayOutcome.OperationFailed => ack.ErrorMessage,
|
||||||
|
// SiteUnreachable is never produced from a ParkedOperationActionAck —
|
||||||
|
// unreachable responses are built by UnreachableRetry/UnreachableDiscard
|
||||||
|
// before any ack is classified, so this arm is unreachable by construction.
|
||||||
|
SiteCallRelayOutcome.SiteUnreachable => ack.ErrorMessage,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(outcome), outcome, "unknown SiteCallRelayOutcome"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared "site unreachable" detail text for both relay directions.</summary>
|
||||||
|
private const string SiteUnreachableMessage =
|
||||||
|
"The owning site is unreachable; the action was not applied. Retry when the site is back online.";
|
||||||
|
|
||||||
|
private static RetrySiteCallResponse UnreachableRetry(string correlationId)
|
||||||
|
{
|
||||||
|
return new RetrySiteCallResponse(
|
||||||
|
correlationId,
|
||||||
|
SiteCallRelayOutcome.SiteUnreachable,
|
||||||
|
Success: false,
|
||||||
|
SiteReachable: false,
|
||||||
|
ErrorMessage: SiteUnreachableMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DiscardSiteCallResponse UnreachableDiscard(string correlationId)
|
||||||
|
{
|
||||||
|
return new DiscardSiteCallResponse(
|
||||||
|
correlationId,
|
||||||
|
SiteCallRelayOutcome.SiteUnreachable,
|
||||||
|
Success: false,
|
||||||
|
SiteReachable: false,
|
||||||
|
ErrorMessage: SiteUnreachableMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves an <see cref="ISiteCallAuditRepository"/> for one read message.
|
||||||
|
/// In test mode the injected instance is returned with a null scope; in
|
||||||
|
/// production a fresh DI scope is created and returned so the caller can
|
||||||
|
/// dispose it once the read completes — the same scope-per-message pattern
|
||||||
|
/// as <see cref="OnUpsertAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
private (IServiceScope? Scope, ISiteCallAuditRepository Repository) ResolveRepository()
|
||||||
|
{
|
||||||
|
if (_injectedRepository is not null)
|
||||||
|
{
|
||||||
|
return (null, _injectedRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = _serviceProvider!.CreateScope();
|
||||||
|
return (scope, scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A cached call counts as stuck when it is still non-terminal and was
|
||||||
|
/// created before <paramref name="stuckCutoff"/>. Non-terminal is keyed off
|
||||||
|
/// <see cref="SiteCall.TerminalAtUtc"/> being <c>null</c> — the
|
||||||
|
/// <c>SiteCalls</c> operational mirror stores <c>AuditStatus</c>-derived
|
||||||
|
/// status strings (<c>Attempted</c>/<c>Delivered</c>/<c>Parked</c>/...), not
|
||||||
|
/// the tracking-lifecycle <c>Pending</c>/<c>Retrying</c> names the spec's
|
||||||
|
/// KPI section uses, so there is no status string that means "buffered".
|
||||||
|
/// <c>TerminalAtUtc</c> is the entity's own active/terminal discriminator
|
||||||
|
/// and is consistent with the repository KPI counts and
|
||||||
|
/// <c>PurgeTerminalAsync</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsStuck(SiteCall row, DateTime stuckCutoff)
|
||||||
|
{
|
||||||
|
return row.TerminalAtUtc is null && row.CreatedAtUtc < stuckCutoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteCallSummary ToSummary(SiteCall row, DateTime stuckCutoff)
|
||||||
|
{
|
||||||
|
return new SiteCallSummary(
|
||||||
|
TrackedOperationId: row.TrackedOperationId.Value,
|
||||||
|
SourceSite: row.SourceSite,
|
||||||
|
Channel: row.Channel,
|
||||||
|
Target: row.Target,
|
||||||
|
Status: row.Status,
|
||||||
|
RetryCount: row.RetryCount,
|
||||||
|
LastError: row.LastError,
|
||||||
|
HttpStatus: row.HttpStatus,
|
||||||
|
CreatedAtUtc: row.CreatedAtUtc,
|
||||||
|
UpdatedAtUtc: row.UpdatedAtUtc,
|
||||||
|
TerminalAtUtc: row.TerminalAtUtc,
|
||||||
|
IsStuck: IsStuck(row, stuckCutoff));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteCallDetail ToDetail(SiteCall row)
|
||||||
|
{
|
||||||
|
return new SiteCallDetail(
|
||||||
|
TrackedOperationId: row.TrackedOperationId.Value,
|
||||||
|
SourceSite: row.SourceSite,
|
||||||
|
Channel: row.Channel,
|
||||||
|
Target: row.Target,
|
||||||
|
Status: row.Status,
|
||||||
|
RetryCount: row.RetryCount,
|
||||||
|
LastError: row.LastError,
|
||||||
|
HttpStatus: row.HttpStatus,
|
||||||
|
CreatedAtUtc: row.CreatedAtUtc,
|
||||||
|
UpdatedAtUtc: row.UpdatedAtUtc,
|
||||||
|
TerminalAtUtc: row.TerminalAtUtc,
|
||||||
|
IngestedAtUtc: row.IngestedAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Treats an empty/whitespace filter string as "no constraint" — the
|
||||||
|
/// repository's <see cref="SiteCallQueryFilter"/> interprets <c>null</c> as
|
||||||
|
/// a no-op predicate, so a blank UI filter must collapse to <c>null</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string? NullIfBlank(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the central→site command transport (the <c>CentralCommunicationActor</c>)
|
||||||
|
/// with the <see cref="SiteCallAuditActor"/> so it can relay Retry/Discard
|
||||||
|
/// actions on parked cached calls to their owning sites. Sent by the Host after
|
||||||
|
/// both actors exist. Lives here (not in Commons) because it carries an
|
||||||
|
/// <see cref="IActorRef"/> and <c>ScadaLink.Commons</c> has no Akka reference —
|
||||||
|
/// the same rationale as <c>RegisterAuditIngest</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RegisterCentralCommunication(IActorRef CentralCommunication);
|
||||||
|
|||||||
47
src/ScadaLink.SiteCallAudit/SiteCallAuditOptions.cs
Normal file
47
src/ScadaLink.SiteCallAudit/SiteCallAuditOptions.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
namespace ScadaLink.SiteCallAudit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for the Site Call Audit (#22) read-side: stuck-call
|
||||||
|
/// detection and KPI windowing. Mirrors the KPI-relevant subset of
|
||||||
|
/// <c>NotificationOutboxOptions</c> — the reconciliation, purge and dispatch
|
||||||
|
/// cadence options the Notification Outbox carries are not part of the Site
|
||||||
|
/// Call Audit read-side backend and are deliberately omitted here.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteCallAuditOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Age past which a non-terminal cached call (<c>Pending</c>/<c>Retrying</c>)
|
||||||
|
/// is considered stuck. Display-only — surfaced as the Stuck KPI and a row
|
||||||
|
/// badge, with no escalation. Default 10 minutes, matching
|
||||||
|
/// <c>NotificationOutboxOptions.StuckAgeThreshold</c>.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan StuckAgeThreshold { get; set; } = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trailing window used to compute the delivered- and failed-last-interval
|
||||||
|
/// throughput KPIs. Default 1 minute, matching
|
||||||
|
/// <c>NotificationOutboxOptions.DeliveredKpiWindow</c>.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan KpiInterval { get; set; } = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 5 (#22): Ask timeout for the central→site Retry/Discard relay. When
|
||||||
|
/// the owning site does not ack a <c>RetryParkedOperation</c> /
|
||||||
|
/// <c>DiscardParkedOperation</c> within this window — site offline, no
|
||||||
|
/// ClusterClient route, or central buffering deliberately absent — the relay
|
||||||
|
/// reports a <c>SiteUnreachable</c> outcome. Default 10 seconds: long enough
|
||||||
|
/// to absorb a healthy cross-cluster round-trip, short enough that an
|
||||||
|
/// operator clicking Retry on an offline site gets a fast, honest answer.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ordering invariant:</b> <c>RelayTimeout</c> must stay below
|
||||||
|
/// <c>CommunicationOptions.QueryTimeout</c> (default 30s), the timeout the
|
||||||
|
/// outer <c>CommunicationService.RetrySiteCallAsync</c>/<c>DiscardSiteCallAsync</c>
|
||||||
|
/// Ask of the <c>SiteCallAuditActor</c> uses. The outer Ask must outlive this
|
||||||
|
/// inner site relay Ask so the inner relay times out first and yields the
|
||||||
|
/// distinct <c>SiteUnreachable</c> outcome; if the outer Ask expired first,
|
||||||
|
/// that outcome would be lost to a generic Ask-timeout exception. The
|
||||||
|
/// defaults (10s < 30s) satisfy this — keep the gap when tuning either.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan RelayTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
}
|
||||||
@@ -24,6 +24,13 @@ public class ParkedMessageHandlerActor : ReceiveActor
|
|||||||
Receive<ParkedMessageQueryRequest>(HandleQuery);
|
Receive<ParkedMessageQueryRequest>(HandleQuery);
|
||||||
Receive<ParkedMessageRetryRequest>(HandleRetry);
|
Receive<ParkedMessageRetryRequest>(HandleRetry);
|
||||||
Receive<ParkedMessageDiscardRequest>(HandleDiscard);
|
Receive<ParkedMessageDiscardRequest>(HandleDiscard);
|
||||||
|
|
||||||
|
// Task 5 (#22): central→site Retry/Discard relay for parked cached
|
||||||
|
// operations. The cached call's S&F buffer message id is the
|
||||||
|
// TrackedOperationId, so these reuse the same parked-message primitive
|
||||||
|
// as HandleRetry/HandleDiscard, keyed off the tracked id.
|
||||||
|
Receive<RetryParkedOperation>(HandleRetryParkedOperation);
|
||||||
|
Receive<DiscardParkedOperation>(HandleDiscardParkedOperation);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleQuery(ParkedMessageQueryRequest msg)
|
private void HandleQuery(ParkedMessageQueryRequest msg)
|
||||||
@@ -90,6 +97,46 @@ public class ParkedMessageHandlerActor : ReceiveActor
|
|||||||
msg.CorrelationId, false, ex.GetBaseException().Message));
|
msg.CorrelationId, false, ex.GetBaseException().Message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 5 (#22): executes a central-relayed Retry of a parked cached call.
|
||||||
|
/// The tracked id is the S&F buffer message id, so this reuses
|
||||||
|
/// <see cref="StoreAndForwardService.RetryParkedMessageAsync"/> — which only
|
||||||
|
/// touches rows that are actually <c>Parked</c> (a non-parked or unknown
|
||||||
|
/// operation yields <c>false</c>, a safe no-op). Central never mutates the
|
||||||
|
/// central <c>SiteCalls</c> mirror; the reset row's corrected state flows
|
||||||
|
/// back via the normal cached-call telemetry path.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleRetryParkedOperation(RetryParkedOperation msg)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
|
||||||
|
_service.RetryParkedMessageAsync(msg.TrackedOperationId.ToString())
|
||||||
|
.PipeTo(
|
||||||
|
sender,
|
||||||
|
success: applied => new ParkedOperationActionAck(
|
||||||
|
msg.CorrelationId, applied, ErrorMessage: null),
|
||||||
|
failure: ex => new ParkedOperationActionAck(
|
||||||
|
msg.CorrelationId, Applied: false, ex.GetBaseException().Message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 5 (#22): executes a central-relayed Discard of a parked cached call.
|
||||||
|
/// Mirrors <see cref="HandleRetryParkedOperation"/>; Discard removes the
|
||||||
|
/// parked S&F buffer row (only when it is actually <c>Parked</c>).
|
||||||
|
/// </summary>
|
||||||
|
private void HandleDiscardParkedOperation(DiscardParkedOperation msg)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
|
||||||
|
_service.DiscardParkedMessageAsync(msg.TrackedOperationId.ToString())
|
||||||
|
.PipeTo(
|
||||||
|
sender,
|
||||||
|
success: applied => new ParkedOperationActionAck(
|
||||||
|
msg.CorrelationId, applied, ErrorMessage: null),
|
||||||
|
failure: ex => new ParkedOperationActionAck(
|
||||||
|
msg.CorrelationId, Applied: false, ex.GetBaseException().Message));
|
||||||
|
}
|
||||||
|
|
||||||
private static string ExtractMethodName(string payloadJson, Commons.Types.Enums.StoreAndForwardCategory category)
|
private static string ExtractMethodName(string payloadJson, Commons.Types.Enums.StoreAndForwardCategory category)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(payloadJson))
|
if (string.IsNullOrEmpty(payloadJson))
|
||||||
|
|||||||
@@ -356,6 +356,12 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
_inner.QueryAsync(filter, paging, ct);
|
_inner.QueryAsync(filter, paging, ct);
|
||||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||||
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -387,5 +393,11 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
_inner.QueryAsync(filter, paging, ct);
|
_inner.QueryAsync(filter, paging, ct);
|
||||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||||
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
|
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
|
||||||
using ScadaLink.AuditLog.Telemetry;
|
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Messages.Integration;
|
using ScadaLink.Commons.Messages.Integration;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
@@ -55,7 +54,7 @@ public class CombinedTelemetryIdempotencyTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
{
|
{
|
||||||
var dto = new CachedTelemetryPacket
|
var dto = new CachedTelemetryPacket
|
||||||
{
|
{
|
||||||
AuditEvent = AuditEventMapper.ToDto(new AuditEvent
|
AuditEvent = AuditEventDtoMapper.ToDto(new AuditEvent
|
||||||
{
|
{
|
||||||
EventId = eventId,
|
EventId = eventId,
|
||||||
OccurredAtUtc = nowUtc,
|
OccurredAtUtc = nowUtc,
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var readRepo = new AuditLogRepository(readContext);
|
var readRepo = new AuditLogRepository(readContext);
|
||||||
var rows = await readRepo.QueryAsync(
|
var rows = await readRepo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
var evt = Assert.Single(rows);
|
var evt = Assert.Single(rows);
|
||||||
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||||
@@ -282,7 +282,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var readRepo = new AuditLogRepository(readContext);
|
var readRepo = new AuditLogRepository(readContext);
|
||||||
var rows = await readRepo.QueryAsync(
|
var rows = await readRepo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
var evt = Assert.Single(rows);
|
var evt = Assert.Single(rows);
|
||||||
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using ScadaLink.AuditLog.Site.Telemetry;
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
using ScadaLink.AuditLog.Telemetry;
|
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.Integration;
|
using ScadaLink.Commons.Messages.Integration;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
@@ -88,7 +87,7 @@ public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder
|
|||||||
{
|
{
|
||||||
return new CachedTelemetryPacket
|
return new CachedTelemetryPacket
|
||||||
{
|
{
|
||||||
AuditEvent = AuditEventMapper.ToDto(telemetry.Audit),
|
AuditEvent = AuditEventDtoMapper.ToDto(telemetry.Audit),
|
||||||
Operational = ToOperationalDto(telemetry.Operational),
|
Operational = ToOperationalDto(telemetry.Operational),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using ScadaLink.AuditLog.Site.Telemetry;
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
using ScadaLink.AuditLog.Telemetry;
|
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Messages.Audit;
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
using ScadaLink.Commons.Types;
|
|
||||||
using ScadaLink.Communication.Grpc;
|
using ScadaLink.Communication.Grpc;
|
||||||
|
|
||||||
namespace ScadaLink.AuditLog.Tests.Integration.Infrastructure;
|
namespace ScadaLink.AuditLog.Tests.Integration.Infrastructure;
|
||||||
@@ -88,7 +86,7 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
var events = new List<AuditEvent>(batch.Events.Count);
|
var events = new List<AuditEvent>(batch.Events.Count);
|
||||||
foreach (var dto in batch.Events)
|
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.
|
// Ask the central actor; the reply carries the accepted EventIds.
|
||||||
@@ -114,10 +112,9 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
/// back into the proto ack.
|
/// back into the proto ack.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Uses the shared <see cref="AuditEventMapper.FromDto"/> for the audit half;
|
/// Uses the shared <see cref="AuditEventDtoMapper.FromDto"/> for the audit half
|
||||||
/// the SiteCall DTO is decoded inline because the AuditLog mapper does not
|
/// and <see cref="SiteCallDtoMapper.FromDto"/> for the SiteCall half — the same
|
||||||
/// (and should not) know about <see cref="SiteCallOperationalDto"/> — the
|
/// canonical mappers the production <c>SiteStreamGrpcServer</c> uses.
|
||||||
/// production gRPC server (Bundle D) uses the same inline shape.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
public async Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -132,8 +129,8 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
|
var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
|
||||||
foreach (var packet in batch.Packets)
|
foreach (var packet in batch.Packets)
|
||||||
{
|
{
|
||||||
var audit = AuditEventMapper.FromDto(packet.AuditEvent);
|
var audit = AuditEventDtoMapper.FromDto(packet.AuditEvent);
|
||||||
var siteCall = MapSiteCallFromDto(packet.Operational);
|
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
|
||||||
entries.Add(new CachedTelemetryEntry(audit, siteCall));
|
entries.Add(new CachedTelemetryEntry(audit, siteCall));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,28 +147,4 @@ public sealed class DirectActorSiteStreamAuditClient : ISiteStreamAuditClient
|
|||||||
}
|
}
|
||||||
return ack;
|
return ack;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Mirrors <c>SiteStreamGrpcServer.MapSiteCallFromDto</c> — keep the two in
|
|
||||||
/// sync. The placeholder <see cref="SiteCall.IngestedAtUtc"/> stamped here
|
|
||||||
/// is overwritten by the central ingest actor inside the dual-write
|
|
||||||
/// transaction, so the value sent on the wire is informational only.
|
|
||||||
/// </summary>
|
|
||||||
private static SiteCall MapSiteCallFromDto(SiteCallOperationalDto dto) => new()
|
|
||||||
{
|
|
||||||
TrackedOperationId = TrackedOperationId.Parse(dto.TrackedOperationId),
|
|
||||||
Channel = dto.Channel,
|
|
||||||
Target = dto.Target,
|
|
||||||
SourceSite = dto.SourceSite,
|
|
||||||
Status = dto.Status,
|
|
||||||
RetryCount = dto.RetryCount,
|
|
||||||
LastError = string.IsNullOrEmpty(dto.LastError) ? null : dto.LastError,
|
|
||||||
HttpStatus = dto.HttpStatus,
|
|
||||||
CreatedAtUtc = DateTime.SpecifyKind(dto.CreatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
||||||
UpdatedAtUtc = DateTime.SpecifyKind(dto.UpdatedAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
||||||
TerminalAtUtc = dto.TerminalAtUtc is null
|
|
||||||
? null
|
|
||||||
: DateTime.SpecifyKind(dto.TerminalAtUtc.ToDateTime(), DateTimeKind.Utc),
|
|
||||||
IngestedAtUtc = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
await using var ctx = CreateContext();
|
await using var ctx = CreateContext();
|
||||||
var repo = new AuditLogRepository(ctx);
|
var repo = new AuditLogRepository(ctx);
|
||||||
var rows = await repo.QueryAsync(
|
var rows = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 50));
|
new AuditLogPaging(PageSize: 50));
|
||||||
// 1 Submit + 1 Attempted = 2 rows so far.
|
// 1 Submit + 1 Attempted = 2 rows so far.
|
||||||
Assert.Equal(2, rows.Count);
|
Assert.Equal(2, rows.Count);
|
||||||
@@ -257,7 +257,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
|
|||||||
await using var ctx = CreateContext();
|
await using var ctx = CreateContext();
|
||||||
var repo = new AuditLogRepository(ctx);
|
var repo = new AuditLogRepository(ctx);
|
||||||
var rows = await repo.QueryAsync(
|
var rows = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 50));
|
new AuditLogPaging(PageSize: 50));
|
||||||
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
|
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
|
||||||
Assert.InRange(rows.Count, 3, 4);
|
Assert.InRange(rows.Count, 3, 4);
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var readRepo = new AuditLogRepository(readContext);
|
var readRepo = new AuditLogRepository(readContext);
|
||||||
var rows = await readRepo.QueryAsync(
|
var rows = await readRepo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
Assert.Single(rows);
|
Assert.Single(rows);
|
||||||
Assert.Equal(evt.EventId, rows[0].EventId);
|
Assert.Equal(evt.EventId, rows[0].EventId);
|
||||||
@@ -207,7 +207,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var readRepo = new AuditLogRepository(readContext);
|
var readRepo = new AuditLogRepository(readContext);
|
||||||
var rows = await readRepo.QueryAsync(
|
var rows = await readRepo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
Assert.Single(rows);
|
Assert.Single(rows);
|
||||||
Assert.Equal(evt.EventId, rows[0].EventId);
|
Assert.Equal(evt.EventId, rows[0].EventId);
|
||||||
@@ -260,7 +260,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
|
|||||||
await using var readContext = CreateContext();
|
await using var readContext = CreateContext();
|
||||||
var readRepo = new AuditLogRepository(readContext);
|
var readRepo = new AuditLogRepository(readContext);
|
||||||
var rows = await readRepo.QueryAsync(
|
var rows = await readRepo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
Assert.Single(rows);
|
Assert.Single(rows);
|
||||||
Assert.Equal(sharedId, rows[0].EventId);
|
Assert.Equal(sharedId, rows[0].EventId);
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Communication.Grpc;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Site.Telemetry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="ClusterClientSiteAuditClient"/> — the production
|
||||||
|
/// <see cref="ISiteStreamAuditClient"/> binding wired by the Host for site
|
||||||
|
/// roles. The client maps the proto-DTO batches produced by
|
||||||
|
/// <see cref="SiteAuditTelemetryActor"/> into the Akka
|
||||||
|
/// <see cref="IngestAuditEventsCommand"/> / <see cref="IngestCachedTelemetryCommand"/>
|
||||||
|
/// messages, Asks the site's <c>SiteCommunicationActor</c> (which forwards over
|
||||||
|
/// ClusterClient to central), and maps the reply back into an
|
||||||
|
/// <see cref="IngestAck"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A <see cref="TestProbe"/> stands in for the <c>SiteCommunicationActor</c>:
|
||||||
|
/// it lets the tests assert the exact command shape AND drive the reply (or
|
||||||
|
/// withhold one to exercise the Ask-timeout path).
|
||||||
|
/// </remarks>
|
||||||
|
public class ClusterClientSiteAuditClientTests : TestKit
|
||||||
|
{
|
||||||
|
/// <summary>Short Ask timeout so the timeout test completes quickly.</summary>
|
||||||
|
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<AuditEvent> 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<IngestAuditEventsCommand>(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<IngestAuditEventsCommand>(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<IngestAuditEventsCommand>(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(() => 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<IngestAuditEventsCommand>(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<Exception>(() => 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<IngestCachedTelemetryCommand>(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<IngestCachedTelemetryCommand>(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<Exception>(() => task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,8 +63,8 @@ public class AuditExportCommandTests
|
|||||||
Until = "2026-05-20T12:00:00Z",
|
Until = "2026-05-20T12:00:00Z",
|
||||||
Format = "jsonl",
|
Format = "jsonl",
|
||||||
Output = "/tmp/x",
|
Output = "/tmp/x",
|
||||||
Channel = "Notification",
|
Channel = new[] { "Notification" },
|
||||||
Site = "site-9",
|
Site = new[] { "site-9" },
|
||||||
};
|
};
|
||||||
var qs = AuditExportHelpers.BuildQueryString(args, now);
|
var qs = AuditExportHelpers.BuildQueryString(args, now);
|
||||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
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"]);
|
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 -----------------------------------------
|
// ---- Streaming export to file -----------------------------------------
|
||||||
|
|
||||||
private sealed class BodyHandler : HttpMessageHandler
|
private sealed class BodyHandler : HttpMessageHandler
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ public class AuditQueryCommandTests
|
|||||||
{
|
{
|
||||||
Since = "1h",
|
Since = "1h",
|
||||||
Until = "2026-05-20T12:00:00Z",
|
Until = "2026-05-20T12:00:00Z",
|
||||||
Channel = "ApiOutbound",
|
Channel = new[] { "ApiOutbound" },
|
||||||
Kind = "ApiCallCached",
|
Kind = new[] { "ApiCallCached" },
|
||||||
Status = "Delivered",
|
Status = new[] { "Delivered" },
|
||||||
Site = "site-1",
|
Site = new[] { "site-1" },
|
||||||
Target = "weather-api",
|
Target = "weather-api",
|
||||||
Actor = "multi-role",
|
Actor = "multi-role",
|
||||||
CorrelationId = "abc-123",
|
CorrelationId = "abc-123",
|
||||||
@@ -76,7 +76,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("ApiCallCached", parsed["kind"]);
|
Assert.Equal("ApiCallCached", parsed["kind"]);
|
||||||
Assert.Equal("Delivered", parsed["status"]);
|
Assert.Equal("Delivered", parsed["status"]);
|
||||||
Assert.Equal("site-1", parsed["sourceSiteId"]);
|
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.Null(parsed["instance"]);
|
||||||
Assert.Equal("weather-api", parsed["target"]);
|
Assert.Equal("weather-api", parsed["target"]);
|
||||||
Assert.Equal("multi-role", parsed["actor"]);
|
Assert.Equal("multi-role", parsed["actor"]);
|
||||||
@@ -96,6 +96,43 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("Failed", parsed["status"]);
|
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]
|
[Fact]
|
||||||
public void BuildQueryString_Cursor_AppendsAfterParameters()
|
public void BuildQueryString_Cursor_AppendsAfterParameters()
|
||||||
{
|
{
|
||||||
@@ -254,6 +291,38 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Empty(parse.Errors);
|
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]
|
[Fact]
|
||||||
public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit()
|
public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end coverage for the Audit Log results-grid column UX (#23
|
||||||
|
/// follow-ups Task 10): drag-to-resize and drag-to-reorder columns, with the
|
||||||
|
/// chosen widths + order persisted in the browser's <c>sessionStorage</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The drag interaction is browser-side (<c>wwwroot/js/audit-grid.js</c>), so
|
||||||
|
/// Playwright — not bUnit — is the right tool: bUnit cannot drive the native
|
||||||
|
/// HTML5 drag-and-drop or pointer-capture resize. Each test seeds one
|
||||||
|
/// <c>AuditLog</c> row via <see cref="AuditDataSeeder"/> so the grid has a
|
||||||
|
/// header row to act on, then best-effort deletes it.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
|
||||||
|
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
|
||||||
|
/// matching the established <see cref="SiteCalls.SiteCallsPageTests"/> idiom.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Playwright")]
|
||||||
|
public class AuditGridColumnTests
|
||||||
|
{
|
||||||
|
private const string AuditLogUrl = "/audit/log";
|
||||||
|
|
||||||
|
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
||||||
|
private const string DbUnavailableSkipReason =
|
||||||
|
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
|
||||||
|
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
|
||||||
|
|
||||||
|
private readonly PlaywrightFixture _fixture;
|
||||||
|
|
||||||
|
public AuditGridColumnTests(PlaywrightFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds one audit row, opens the Audit Log page, and clicks Apply so the
|
||||||
|
/// results grid renders a header row the column tests can act on.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IPage> OpenGridWithSeededRowAsync(string targetPrefix, Guid eventId)
|
||||||
|
{
|
||||||
|
await AuditDataSeeder.InsertAuditEventAsync(
|
||||||
|
eventId: eventId,
|
||||||
|
occurredAtUtc: DateTime.UtcNow,
|
||||||
|
channel: "ApiOutbound",
|
||||||
|
kind: "ApiCall",
|
||||||
|
status: "Delivered",
|
||||||
|
target: targetPrefix + "endpoint",
|
||||||
|
httpStatus: 200,
|
||||||
|
durationMs: 25);
|
||||||
|
|
||||||
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||||
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{AuditLogUrl}");
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
// Apply with no chips — the default LastHour range matches the fresh row.
|
||||||
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
var row = page.Locator($"[data-test='grid-row-{eventId}']");
|
||||||
|
await Assertions.Expect(row).ToBeVisibleAsync();
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pixel width of a header cell, measured from its bounding box.</summary>
|
||||||
|
private static async Task<double> HeaderWidthAsync(IPage page, string columnKey)
|
||||||
|
{
|
||||||
|
var box = await page.Locator($"[data-col-key='{columnKey}']").BoundingBoxAsync();
|
||||||
|
Assert.NotNull(box);
|
||||||
|
return box!.Width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The ordered list of column keys as currently rendered in the header.</summary>
|
||||||
|
private static async Task<IReadOnlyList<string>> HeaderOrderAsync(IPage page)
|
||||||
|
{
|
||||||
|
return await page.Locator("thead th[data-col-key]")
|
||||||
|
.EvaluateAllAsync<string[]>("els => els.map(e => e.getAttribute('data-col-key'))");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls until <paramref name="storageKey"/> has been written to
|
||||||
|
/// <c>sessionStorage</c>. The grid persists a resize/reorder
|
||||||
|
/// asynchronously — the browser-side drag fires a fire-and-forget
|
||||||
|
/// JS→.NET invoke (<c>OnColumnResized</c>/<c>OnColumnReordered</c>), and
|
||||||
|
/// the .NET handler then round-trips back through JS interop to write
|
||||||
|
/// <c>sessionStorage</c>. A bare <c>getItem</c> immediately after the drag
|
||||||
|
/// races that round-trip; this waits for the key to actually land.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task WaitForStorageKeyAsync(IPage page, string storageKey)
|
||||||
|
{
|
||||||
|
await page.WaitForFunctionAsync(
|
||||||
|
"key => sessionStorage.getItem(key) !== null", storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls until the header's first column key equals <paramref name="expectedFirstKey"/>.
|
||||||
|
/// A drag-to-reorder re-renders the header asynchronously (the JS→.NET
|
||||||
|
/// <c>OnColumnReordered</c> invoke is fire-and-forget), so reading the
|
||||||
|
/// header order synchronously after <c>DragToAsync</c> can observe the
|
||||||
|
/// pre-reorder layout. This waits for the re-render to settle.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task WaitForFirstColumnAsync(IPage page, string expectedFirstKey)
|
||||||
|
{
|
||||||
|
await page.WaitForFunctionAsync(
|
||||||
|
"key => { var th = document.querySelector('thead th[data-col-key]'); " +
|
||||||
|
"return th && th.getAttribute('data-col-key') === key; }",
|
||||||
|
expectedFirstKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload()
|
||||||
|
{
|
||||||
|
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||||
|
|
||||||
|
var runId = Guid.NewGuid().ToString("N");
|
||||||
|
var targetPrefix = $"playwright-test/grid-resize/{runId}/";
|
||||||
|
var eventId = Guid.NewGuid();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
||||||
|
|
||||||
|
const string columnKey = "Target";
|
||||||
|
var before = await HeaderWidthAsync(page, columnKey);
|
||||||
|
|
||||||
|
// Drag the resize handle on the column's right edge 120px to the
|
||||||
|
// right. The handle is a thin strip; grab its centre and drag.
|
||||||
|
var handle = page.Locator($"[data-test='col-resize-{columnKey}']");
|
||||||
|
var handleBox = await handle.BoundingBoxAsync();
|
||||||
|
Assert.NotNull(handleBox);
|
||||||
|
var startX = handleBox!.X + handleBox.Width / 2;
|
||||||
|
var startY = handleBox.Y + handleBox.Height / 2;
|
||||||
|
|
||||||
|
await page.Mouse.MoveAsync(startX, startY);
|
||||||
|
await page.Mouse.DownAsync();
|
||||||
|
await page.Mouse.MoveAsync(startX + 120, startY, new MouseMoveOptions { Steps = 8 });
|
||||||
|
await page.Mouse.UpAsync();
|
||||||
|
|
||||||
|
var after = await HeaderWidthAsync(page, columnKey);
|
||||||
|
Assert.True(after > before + 40,
|
||||||
|
$"Expected the {columnKey} column to widen after the resize drag (before={before}, after={after}).");
|
||||||
|
|
||||||
|
// Reload: the persisted width is restored from sessionStorage.
|
||||||
|
await page.ReloadAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
var afterReload = await HeaderWidthAsync(page, columnKey);
|
||||||
|
// Allow a small tolerance for sub-pixel layout rounding.
|
||||||
|
Assert.True(Math.Abs(afterReload - after) < 8,
|
||||||
|
$"Expected the resized width to survive a reload (after={after}, afterReload={afterReload}).");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task ReorderDrag_MovesColumn_AndSurvivesReload()
|
||||||
|
{
|
||||||
|
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||||
|
|
||||||
|
var runId = Guid.NewGuid().ToString("N");
|
||||||
|
var targetPrefix = $"playwright-test/grid-reorder/{runId}/";
|
||||||
|
var eventId = Guid.NewGuid();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
||||||
|
|
||||||
|
var initialOrder = await HeaderOrderAsync(page);
|
||||||
|
// Default order opens with OccurredAtUtc first, Status fifth.
|
||||||
|
Assert.Equal("OccurredAtUtc", initialOrder[0]);
|
||||||
|
Assert.Contains("Status", initialOrder);
|
||||||
|
|
||||||
|
// Drag the Status header onto the OccurredAtUtc header — Status
|
||||||
|
// should move into the leading slot.
|
||||||
|
var source = page.Locator("[data-col-key='Status']");
|
||||||
|
var target = page.Locator("[data-col-key='OccurredAtUtc']");
|
||||||
|
await source.DragToAsync(target);
|
||||||
|
// The reorder re-renders the header asynchronously (fire-and-forget
|
||||||
|
// JS→.NET invoke); wait for it to settle before reading the order.
|
||||||
|
await WaitForFirstColumnAsync(page, "Status");
|
||||||
|
|
||||||
|
var afterOrder = await HeaderOrderAsync(page);
|
||||||
|
Assert.Equal("Status", afterOrder[0]);
|
||||||
|
Assert.True(afterOrder.ToList().IndexOf("Status") < afterOrder.ToList().IndexOf("OccurredAtUtc"),
|
||||||
|
"Expected Status to be reordered ahead of OccurredAtUtc.");
|
||||||
|
|
||||||
|
// Reload: the persisted order is restored from sessionStorage on
|
||||||
|
// the grid's first render — wait for the header to reflect it.
|
||||||
|
await page.ReloadAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
await WaitForFirstColumnAsync(page, "Status");
|
||||||
|
|
||||||
|
var afterReload = await HeaderOrderAsync(page);
|
||||||
|
Assert.Equal("Status", afterReload[0]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage()
|
||||||
|
{
|
||||||
|
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||||
|
|
||||||
|
var runId = Guid.NewGuid().ToString("N");
|
||||||
|
var targetPrefix = $"playwright-test/grid-persist/{runId}/";
|
||||||
|
var eventId = Guid.NewGuid();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
||||||
|
|
||||||
|
// Reorder then resize, then confirm sessionStorage carries both.
|
||||||
|
await page.Locator("[data-col-key='Status']")
|
||||||
|
.DragToAsync(page.Locator("[data-col-key='OccurredAtUtc']"));
|
||||||
|
// Wait for the reorder re-render to settle before measuring the
|
||||||
|
// resize handle, so the handle's bounding box is read off the
|
||||||
|
// post-reorder layout.
|
||||||
|
await WaitForFirstColumnAsync(page, "Status");
|
||||||
|
|
||||||
|
var handle = page.Locator("[data-test='col-resize-Target']");
|
||||||
|
var handleBox = await handle.BoundingBoxAsync();
|
||||||
|
Assert.NotNull(handleBox);
|
||||||
|
var startX = handleBox!.X + handleBox.Width / 2;
|
||||||
|
var startY = handleBox.Y + handleBox.Height / 2;
|
||||||
|
await page.Mouse.MoveAsync(startX, startY);
|
||||||
|
await page.Mouse.DownAsync();
|
||||||
|
await page.Mouse.MoveAsync(startX + 90, startY, new MouseMoveOptions { Steps = 6 });
|
||||||
|
await page.Mouse.UpAsync();
|
||||||
|
|
||||||
|
// Both keys are written under the auditGrid: namespace — but the
|
||||||
|
// write is asynchronous: pointer-up fires a fire-and-forget
|
||||||
|
// OnColumnResized/OnColumnReordered JS→.NET invoke, and the .NET
|
||||||
|
// handler then round-trips back through JS interop to call
|
||||||
|
// auditGrid.save. Reading sessionStorage synchronously right after
|
||||||
|
// Mouse.UpAsync races that round-trip, so poll for both keys to
|
||||||
|
// land before asserting on them.
|
||||||
|
await WaitForStorageKeyAsync(page, "auditGrid:columnOrder");
|
||||||
|
await WaitForStorageKeyAsync(page, "auditGrid:columnWidths");
|
||||||
|
var orderJson = await page.EvaluateAsync<string?>(
|
||||||
|
"() => sessionStorage.getItem('auditGrid:columnOrder')");
|
||||||
|
var widthsJson = await page.EvaluateAsync<string?>(
|
||||||
|
"() => sessionStorage.getItem('auditGrid:columnWidths')");
|
||||||
|
Assert.NotNull(orderJson);
|
||||||
|
Assert.Contains("Status", orderJson!);
|
||||||
|
Assert.NotNull(widthsJson);
|
||||||
|
Assert.Contains("Target", widthsJson!);
|
||||||
|
|
||||||
|
// After a reload the restored grid reflects the stored order. The
|
||||||
|
// restore happens on the grid's first render (LoadPersistedState →
|
||||||
|
// StateHasChanged), so wait for the header to reflect it.
|
||||||
|
await page.ReloadAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
await WaitForFirstColumnAsync(page, "Status");
|
||||||
|
|
||||||
|
var restoredOrder = await HeaderOrderAsync(page);
|
||||||
|
Assert.Equal("Status", restoredOrder[0]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
<PackageReference Include="Microsoft.Playwright" />
|
<PackageReference Include="Microsoft.Playwright" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<!--
|
||||||
|
SkippableFact lets the Site Calls E2E tests report as Skipped (not Failed)
|
||||||
|
when the dev cluster / MSSQL is not running. xunit 2.9.x does not ship
|
||||||
|
Assert.Skip / SkipUnless — those are v3-only — so we use the canonical
|
||||||
|
community wrapper, matching ScadaLink.ConfigurationDatabase.Tests.
|
||||||
|
-->
|
||||||
|
<PackageReference Include="Xunit.SkippableFact" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Direct-SQL seeding helper for the Site Calls page Playwright E2E tests
|
||||||
|
/// (Site Call Audit #22, follow-ups Task 6).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The Site Calls page reads the central <c>SiteCalls</c> table through the
|
||||||
|
/// <c>SiteCallAuditActor</c>, which is a pure read-from-table mirror — so a row
|
||||||
|
/// INSERTed directly into <c>SiteCalls</c> surfaces on the page exactly as a
|
||||||
|
/// telemetry-ingested row would. Mirrors <see cref="Audit.AuditDataSeeder"/>:
|
||||||
|
/// each test inserts its own rows at setup and best-effort deletes them at
|
||||||
|
/// teardown, keeping the suite self-contained without touching
|
||||||
|
/// <c>infra/mssql/seed-config.sql</c>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Rows are tagged with a unique <c>Target</c> prefix derived from the test name
|
||||||
|
/// + a GUID so the teardown <c>DELETE</c> never touches rows the cluster itself
|
||||||
|
/// produced. <c>CreatedAtUtc</c>/<c>UpdatedAtUtc</c> are pinned to "now" so the
|
||||||
|
/// page's default (unconstrained) query window sees the row.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connection string for the running cluster's configuration DB. Resolved
|
||||||
|
/// from <c>SCADALINK_PLAYWRIGHT_DB</c> when set, otherwise the local docker
|
||||||
|
/// dev defaults.
|
||||||
|
/// </summary>
|
||||||
|
public static string ConnectionString
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var fromEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||||
|
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultConnectionString : fromEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts a single row into the central <c>SiteCalls</c> table. Optional
|
||||||
|
/// fields are nullable so a test can shape the row to the status/channel it
|
||||||
|
/// needs for its grid assertions. <c>TrackedOperationId</c> is stored as the
|
||||||
|
/// 36-character GUID string the entity mapping expects.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort cleanup. Deletes every <c>SiteCalls</c> row whose <c>Target</c>
|
||||||
|
/// starts with <paramref name="targetPrefix"/>. Swallows all errors — the
|
||||||
|
/// prefix carries a per-run GUID so the rows are unique to this test run.
|
||||||
|
/// </summary>
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="SqlException"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<bool> IsAvailableAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(ConnectionString);
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end coverage for the central Site Calls page (Site Call Audit #22,
|
||||||
|
/// follow-ups Task 6).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Each test seeds its own <c>SiteCalls</c> rows directly into the running
|
||||||
|
/// cluster's configuration database via <see cref="SiteCallDataSeeder"/>,
|
||||||
|
/// exercises the UI through Playwright, then best-effort deletes the rows by
|
||||||
|
/// their <c>Target</c> prefix. The Site Calls page reads the <c>SiteCalls</c>
|
||||||
|
/// table through the <c>SiteCallAuditActor</c> (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
|
||||||
|
/// <c>infra/mssql/seed-config.sql</c>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Scenarios covered (per the Task 6 brief):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>PageLoads</c> — the page renders for a Deployment-role user.</item>
|
||||||
|
/// <item><c>FilterNarrowing</c> — a channel filter narrows the results grid.</item>
|
||||||
|
/// <item><c>DrillIn</c> — the "View audit history" link deep-links into the
|
||||||
|
/// Audit Log pre-filtered to the call's TrackedOperationId.</item>
|
||||||
|
/// <item><c>RetryDiscardVisibility</c> — Retry/Discard appear only on Parked
|
||||||
|
/// rows, never on Failed (or other) rows.</item>
|
||||||
|
/// <item><c>RetryClickThrough</c> — clicking Retry on a Parked row confirms
|
||||||
|
/// the dialog, relays to the owning site, and surfaces an outcome toast.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
|
||||||
|
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
|
||||||
|
/// matching the established <c>ScadaLink.ConfigurationDatabase.Tests</c> idiom.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Playwright")]
|
||||||
|
public class SiteCallsPageTests
|
||||||
|
{
|
||||||
|
private const string SiteCallsUrl = "/site-calls/report";
|
||||||
|
|
||||||
|
private readonly PlaywrightFixture _fixture;
|
||||||
|
|
||||||
|
public SiteCallsPageTests(PlaywrightFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the Target-keyword search box and commits the value to the server
|
||||||
|
/// as its own discrete circuit message before the caller clicks Query.
|
||||||
|
/// <para>
|
||||||
|
/// The <c>#sc-search</c> input is a Blazor <c>@bind</c>
|
||||||
|
/// (commit-on-<c>change</c>): <see cref="ILocator.FillAsync"/> only fires
|
||||||
|
/// <c>input</c> events, and the <c>change</c> that actually updates
|
||||||
|
/// <c>_targetFilter</c> on the server fires on blur. The original test
|
||||||
|
/// relied on the Query <c>ClickAsync</c> itself to blur the field — that
|
||||||
|
/// makes the <c>change</c> (blur) and the <c>click</c> a single, near-
|
||||||
|
/// simultaneous gesture and races them over the SignalR circuit: when the
|
||||||
|
/// <c>click</c> is processed before the <c>change</c> has updated
|
||||||
|
/// <c>_targetFilter</c>, <c>Search()</c> runs with a stale (empty) keyword
|
||||||
|
/// and the grid returns unfiltered rows.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="ILocator.DispatchEventAsync"/> raises the <c>change</c> as a
|
||||||
|
/// fully-awaited action of its own, so its circuit message is enqueued and
|
||||||
|
/// sent before the later Query <c>ClickAsync</c>'s message. The SignalR
|
||||||
|
/// connection delivers messages in send order and the Blazor circuit
|
||||||
|
/// processes them sequentially, so <c>_targetFilter</c> is guaranteed
|
||||||
|
/// committed before <c>Search()</c> runs — the two are no longer one
|
||||||
|
/// racing gesture.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static async Task SetSearchKeywordAsync(IPage page, string keyword)
|
||||||
|
{
|
||||||
|
var search = page.Locator("#sc-search");
|
||||||
|
await search.FillAsync(keyword);
|
||||||
|
// Commit the @bind as a discrete change event — not a blur side effect
|
||||||
|
// of the subsequent Query click.
|
||||||
|
await search.DispatchEventAsync("change");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PageLoads_ForDeploymentUser()
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,10 +160,10 @@ public class AuditExportEndpointsTests
|
|||||||
|
|
||||||
await repo.Received().QueryAsync(
|
await repo.Received().QueryAsync(
|
||||||
Arg.Is<AuditLogQueryFilter>(f =>
|
Arg.Is<AuditLogQueryFilter>(f =>
|
||||||
f.Channel == AuditChannel.ApiOutbound &&
|
f.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound &&
|
||||||
f.Kind == AuditKind.ApiCall &&
|
f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall &&
|
||||||
f.Status == AuditStatus.Failed &&
|
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed &&
|
||||||
f.SourceSiteId == "plant-a" &&
|
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" &&
|
||||||
f.Target == "PaymentApi" &&
|
f.Target == "PaymentApi" &&
|
||||||
f.Actor == "apikey-1" &&
|
f.Actor == "apikey-1" &&
|
||||||
f.CorrelationId == Guid.Parse(correlationId) &&
|
f.CorrelationId == Guid.Parse(correlationId) &&
|
||||||
@@ -188,10 +188,10 @@ public class AuditExportEndpointsTests
|
|||||||
|
|
||||||
await repo.Received().QueryAsync(
|
await repo.Received().QueryAsync(
|
||||||
Arg.Is<AuditLogQueryFilter>(f =>
|
Arg.Is<AuditLogQueryFilter>(f =>
|
||||||
f.Channel == null &&
|
f.Channels == null &&
|
||||||
f.Kind == null &&
|
f.Kinds == null &&
|
||||||
f.Status == null &&
|
f.Statuses == null &&
|
||||||
f.SourceSiteId == null &&
|
f.SourceSiteIds == null &&
|
||||||
f.Target == null &&
|
f.Target == null &&
|
||||||
f.Actor == null &&
|
f.Actor == null &&
|
||||||
f.CorrelationId == null &&
|
f.CorrelationId == null &&
|
||||||
@@ -216,7 +216,7 @@ public class AuditExportEndpointsTests
|
|||||||
_ = await response.Content.ReadAsStringAsync();
|
_ = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
await repo.Received().QueryAsync(
|
await repo.Received().QueryAsync(
|
||||||
Arg.Is<AuditLogQueryFilter>(f => f.Channel == null),
|
Arg.Is<AuditLogQueryFilter>(f => f.Channels == null),
|
||||||
Arg.Any<AuditLogPaging>(),
|
Arg.Any<AuditLogPaging>(),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,10 +77,30 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel);
|
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
|
||||||
Assert.Equal("Plant-A-OPC", captured.Target);
|
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<AuditFilterBar>(p => p
|
||||||
|
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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]
|
[Fact]
|
||||||
public void Channel_Narrows_Kind_Options_When_Selected()
|
public void Channel_Narrows_Kind_Options_When_Selected()
|
||||||
{
|
{
|
||||||
@@ -117,14 +137,38 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
// Single-value filter contract: Failed leads the non-success set.
|
// Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}.
|
||||||
Assert.Equal(AuditStatus.Failed, captured!.Status);
|
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).
|
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
|
||||||
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
||||||
cut.Find("[data-test=\"filter-apply\"]").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<AuditFilterBar>(p => p
|
||||||
|
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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]
|
[Fact]
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
_service = Substitute.For<IAuditLogQueryService>();
|
_service = Substitute.For<IAuditLogQueryService>();
|
||||||
_service.DefaultPageSize.Returns(100);
|
_service.DefaultPageSize.Returns(100);
|
||||||
Services.AddSingleton(_service);
|
Services.AddSingleton(_service);
|
||||||
|
|
||||||
|
// The grid's OnAfterRenderAsync calls into audit-grid.js (init + the
|
||||||
|
// sessionStorage load). Loose mode lets those unconfigured calls no-op
|
||||||
|
// — auditGrid.load returns null (no prior state) unless a test sets up
|
||||||
|
// an explicit JSInterop.Setup to return a stored payload.
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StubPage(IReadOnlyList<AuditEvent> rows)
|
private void StubPage(IReadOnlyList<AuditEvent> rows)
|
||||||
@@ -131,4 +137,133 @@ public class AuditResultsGridTests : BunitContext
|
|||||||
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
|
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
|
||||||
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
|
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- column resize + reorder UX (#23 follow-ups Task 10) ---------------
|
||||||
|
//
|
||||||
|
// The drag interaction itself is browser-side (audit-grid.js) and covered
|
||||||
|
// by the Playwright suite. The bUnit tests below exercise the .NET-side
|
||||||
|
// load/apply/persist logic that the JS callbacks drive: graceful handling
|
||||||
|
// of stored orders, the reorder slot-move maths, and the resize minimum.
|
||||||
|
|
||||||
|
/// <summary>Column keys in default (spec) order — the fallback used everywhere.</summary>
|
||||||
|
private static readonly string[] DefaultOrder =
|
||||||
|
{
|
||||||
|
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
|
||||||
|
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int HeaderIndex(string markup, string key)
|
||||||
|
=> markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn()
|
||||||
|
{
|
||||||
|
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
foreach (var key in DefaultOrder)
|
||||||
|
{
|
||||||
|
// Each <th> carries the stable drag key and a resize handle.
|
||||||
|
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
|
||||||
|
Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ColumnOrderParameter_DrivesHeaderOrder()
|
||||||
|
{
|
||||||
|
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p
|
||||||
|
.Add(c => c.Filter, new AuditLogQueryFilter())
|
||||||
|
.Add(c => c.ColumnOrder, new[] { "Status", "Site" }));
|
||||||
|
|
||||||
|
// Status + Site move to the front; the omitted columns still render,
|
||||||
|
// appended in default order — Status precedes Site precedes Channel.
|
||||||
|
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
|
||||||
|
Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel"));
|
||||||
|
// No column is dropped — all ten headers are present.
|
||||||
|
foreach (var key in DefaultOrder)
|
||||||
|
{
|
||||||
|
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists()
|
||||||
|
{
|
||||||
|
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
// Drag Status onto OccurredAtUtc — Status should land in slot 0.
|
||||||
|
await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc"));
|
||||||
|
|
||||||
|
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc"));
|
||||||
|
// The new order was persisted to sessionStorage under the order key.
|
||||||
|
// Loose-mode JSInterop records every InvokeVoidAsync; find the save call.
|
||||||
|
var save = JSInterop.Invocations
|
||||||
|
.Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder");
|
||||||
|
Assert.Contains("Status", (string)save.Arguments[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists()
|
||||||
|
{
|
||||||
|
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
// A drag that would shrink the column to 10px must clamp to the 64px floor.
|
||||||
|
await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10));
|
||||||
|
|
||||||
|
// The clamped width is reflected as the --audit-col-width custom property.
|
||||||
|
Assert.Contains("--audit-col-width: 64px", cut.Markup);
|
||||||
|
// The width was persisted to sessionStorage under the widths key.
|
||||||
|
Assert.Contains(JSInterop.Invocations,
|
||||||
|
i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StoredOrder_WithUnknownKey_DegradesGracefully()
|
||||||
|
{
|
||||||
|
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
|
||||||
|
// A stale persisted order naming a removed column ("LegacyCol") plus a
|
||||||
|
// subset of real columns — the unknown key must be dropped and the
|
||||||
|
// omitted real columns appended in default order, never throwing.
|
||||||
|
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
|
||||||
|
.SetResult("[\"Status\",\"LegacyCol\",\"Site\"]");
|
||||||
|
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
|
||||||
|
.SetResult((string?)null);
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
// Restored order applied: Status then Site at the front.
|
||||||
|
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
|
||||||
|
// The unknown key produced no header and did not break rendering.
|
||||||
|
Assert.DoesNotContain("LegacyCol", cut.Markup);
|
||||||
|
// All ten real columns still present.
|
||||||
|
foreach (var key in DefaultOrder)
|
||||||
|
{
|
||||||
|
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StoredWidths_ForUnknownColumn_AreIgnored()
|
||||||
|
{
|
||||||
|
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
|
||||||
|
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
|
||||||
|
.SetResult((string?)null);
|
||||||
|
// A width for a real column and one for a removed column.
|
||||||
|
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
|
||||||
|
.SetResult("{\"Target\":220,\"LegacyCol\":300}");
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
// The valid column's width was applied; the stale one silently ignored.
|
||||||
|
Assert.Contains("--audit-col-width: 220px", cut.Markup);
|
||||||
|
Assert.DoesNotContain("300px", cut.Markup);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Bunit.TestDoubles;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ScadaLink.CentralUI.Components.Health;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Components.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for <see cref="SiteCallKpiTiles"/> (Site Call Audit #22, Task 7).
|
||||||
|
/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked —
|
||||||
|
/// from a single <see cref="SiteCallKpiResponse"/> snapshot. The tests pin:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
|
||||||
|
/// <item>Tile values render the snapshot's counters.</item>
|
||||||
|
/// <item>Threshold borders fire correctly — danger on Parked > 0, warning
|
||||||
|
/// on Stuck > 0, none when those counts are zero, none on Buffered.</item>
|
||||||
|
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
|
||||||
|
/// <item>Tile clicks navigate to the correct pre-filtered Site Calls report URL.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class SiteCallKpiTilesTests : BunitContext
|
||||||
|
{
|
||||||
|
private static SiteCallKpiResponse MakeSnapshot(int buffered, int parked, int stuck) =>
|
||||||
|
new(
|
||||||
|
CorrelationId: "k",
|
||||||
|
Success: true,
|
||||||
|
ErrorMessage: null,
|
||||||
|
BufferedCount: buffered,
|
||||||
|
ParkedCount: parked,
|
||||||
|
FailedLastInterval: 0,
|
||||||
|
DeliveredLastInterval: 0,
|
||||||
|
OldestPendingAge: null,
|
||||||
|
StuckCount: stuck);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renders_ThreeTiles_FromSnapshot()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 120, parked: 3, stuck: 7))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
// Three stable data-test selectors — the contract for both these tests
|
||||||
|
// and any future Playwright sweep.
|
||||||
|
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
|
||||||
|
|
||||||
|
// Tile values render the snapshot's counters.
|
||||||
|
Assert.Contains(">120<", cut.Markup); // buffered
|
||||||
|
Assert.Contains(">7<", cut.Markup); // stuck
|
||||||
|
Assert.Contains(">3<", cut.Markup); // parked
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, (SiteCallKpiResponse?)null)
|
||||||
|
.Add(c => c.IsAvailable, false)
|
||||||
|
.Add(c => c.ErrorMessage, "site call repository unavailable"));
|
||||||
|
|
||||||
|
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
|
||||||
|
Assert.Contains("—", cut.Markup);
|
||||||
|
// Inline error message renders below.
|
||||||
|
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
|
||||||
|
Assert.Contains("site call repository unavailable", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
|
||||||
|
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParkedTile_NoDangerBorder_WhenParkedZero()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
|
||||||
|
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StuckTile_GetsWarningBorder_WhenStuckAboveZero()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
|
||||||
|
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
|
||||||
|
// Warning, not danger — Stuck is the softer signal.
|
||||||
|
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StuckTile_NoWarningBorder_WhenStuckZero()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
|
||||||
|
Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount()
|
||||||
|
{
|
||||||
|
// A non-zero buffer is normal operation — the Buffered tile is a plain
|
||||||
|
// count tile and never gets a danger/warning border.
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 5000, parked: 0, stuck: 0))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
|
||||||
|
var cls = tile.GetAttribute("class") ?? string.Empty;
|
||||||
|
Assert.DoesNotContain("border-danger", cls);
|
||||||
|
Assert.DoesNotContain("border-warning", cls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 50, parked: 0, stuck: 0))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
|
||||||
|
tile.Click();
|
||||||
|
|
||||||
|
// Unfiltered /site-calls/report — no query string.
|
||||||
|
Assert.EndsWith("/site-calls/report", nav.Uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
|
||||||
|
tile.Click();
|
||||||
|
|
||||||
|
// Spec: Stuck tile drills into the report's "stuck only" filter.
|
||||||
|
Assert.Contains("/site-calls/report?stuck=true", nav.Uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallKpiTiles>(p => p
|
||||||
|
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
|
||||||
|
.Add(c => c.IsAvailable, true));
|
||||||
|
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
|
||||||
|
tile.Click();
|
||||||
|
|
||||||
|
// Spec: Parked tile drills into ?status=Parked.
|
||||||
|
Assert.Contains("/site-calls/report?status=Parked", nav.Uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,10 +36,10 @@ public class AuditLogPageExportUrlTests
|
|||||||
{
|
{
|
||||||
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
var filter = new AuditLogQueryFilter(
|
var filter = new AuditLogQueryFilter(
|
||||||
Channel: AuditChannel.ApiOutbound,
|
Channels: new[] { AuditChannel.ApiOutbound },
|
||||||
Kind: AuditKind.ApiCall,
|
Kinds: new[] { AuditKind.ApiCall },
|
||||||
Status: AuditStatus.Failed,
|
Statuses: new[] { AuditStatus.Failed },
|
||||||
SourceSiteId: "plant-a",
|
SourceSiteIds: new[] { "plant-a" },
|
||||||
Target: "PaymentApi",
|
Target: "PaymentApi",
|
||||||
Actor: "apikey-1",
|
Actor: "apikey-1",
|
||||||
CorrelationId: corr,
|
CorrelationId: corr,
|
||||||
@@ -65,7 +65,7 @@ public class AuditLogPageExportUrlTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
|
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);
|
var url = AuditLogPage.BuildExportUrl(filter);
|
||||||
|
|
||||||
@@ -74,4 +74,22 @@ public class AuditLogPageExportUrlTests
|
|||||||
Assert.Single(query);
|
Assert.Single(query);
|
||||||
Assert.Equal("Notification", query["channel"]);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogPagePermissionTests : BunitContext
|
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)
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim> { new("Username", "tester") };
|
var claims = new List<Claim> { new("Username", "tester") };
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditLogPageScaffoldTests : BunitContext
|
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)
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim> { new("Username", "tester") };
|
var claims = new List<Claim> { new("Username", "tester") };
|
||||||
@@ -197,7 +206,8 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
_queryService.Received().QueryAsync(
|
_queryService.Received().QueryAsync(
|
||||||
Arg.Is<AuditLogQueryFilter>(f => f.SourceSiteId == "plant-a"),
|
Arg.Is<AuditLogQueryFilter>(f =>
|
||||||
|
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"),
|
||||||
Arg.Any<AuditLogPaging?>(),
|
Arg.Any<AuditLogPaging?>(),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
});
|
});
|
||||||
@@ -218,7 +228,8 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
cut.WaitForAssertion(() =>
|
cut.WaitForAssertion(() =>
|
||||||
{
|
{
|
||||||
_queryService.Received().QueryAsync(
|
_queryService.Received().QueryAsync(
|
||||||
Arg.Is<AuditLogQueryFilter>(f => f.Status == AuditStatus.Failed),
|
Arg.Is<AuditLogQueryFilter>(f =>
|
||||||
|
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed),
|
||||||
Arg.Any<AuditLogPaging?>(),
|
Arg.Any<AuditLogPaging?>(),
|
||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using NSubstitute;
|
|||||||
using ScadaLink.CentralUI.Services;
|
using ScadaLink.CentralUI.Services;
|
||||||
using ScadaLink.Commons.Entities.Sites;
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
using ScadaLink.Commons.Interfaces.Repositories;
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
using ScadaLink.Communication;
|
using ScadaLink.Communication;
|
||||||
@@ -37,6 +38,13 @@ public class HealthPageTests : BunitContext
|
|||||||
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
|
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
|
||||||
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
|
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()
|
public HealthPageTests()
|
||||||
{
|
{
|
||||||
_comms = new CommunicationService(
|
_comms = new CommunicationService(
|
||||||
@@ -45,6 +53,9 @@ public class HealthPageTests : BunitContext
|
|||||||
|
|
||||||
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
|
||||||
_comms.SetNotificationOutbox(outbox);
|
_comms.SetNotificationOutbox(outbox);
|
||||||
|
|
||||||
|
var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
|
||||||
|
_comms.SetSiteCallAudit(siteCallAudit);
|
||||||
Services.AddSingleton(_comms);
|
Services.AddSingleton(_comms);
|
||||||
|
|
||||||
var aggregator = Substitute.For<ICentralHealthAggregator>();
|
var aggregator = Substitute.For<ICentralHealthAggregator>();
|
||||||
@@ -133,6 +144,53 @@ public class HealthPageTests : BunitContext
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renders_SiteCallKpiTiles_WithValues()
|
||||||
|
{
|
||||||
|
var cut = Render<HealthPage>();
|
||||||
|
|
||||||
|
// KPI data arrives via an async actor Ask after first render.
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.Contains("Site Calls", cut.Markup);
|
||||||
|
// The three Site Call tiles render at the documented data-test selectors.
|
||||||
|
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
|
||||||
|
// KPI numeric values surface in the tiles.
|
||||||
|
Assert.Contains(">9<", cut.Markup); // BufferedCount
|
||||||
|
Assert.Contains(">5<", cut.Markup); // StuckCount
|
||||||
|
Assert.Contains(">2<", cut.Markup); // ParkedCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RendersLinkToTheSiteCallsReportPage()
|
||||||
|
{
|
||||||
|
var cut = Render<HealthPage>();
|
||||||
|
var link = cut.Find("a[href='/site-calls/report']");
|
||||||
|
Assert.Contains("View details", link.TextContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallKpiFailure_ShowsGracefulFallback()
|
||||||
|
{
|
||||||
|
_siteCallKpiReply = new SiteCallKpiResponse(
|
||||||
|
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
|
||||||
|
|
||||||
|
var cut = Render<HealthPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
// Failure must not crash the page; tiles fall back to a dash and the
|
||||||
|
// inline error message surfaces.
|
||||||
|
Assert.Contains("Site Calls", cut.Markup);
|
||||||
|
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
|
||||||
|
Assert.Contains("site call repository unavailable", cut.Markup);
|
||||||
|
Assert.Contains(">—<", cut.Markup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OutboxKpiFailure_ShowsGracefulFallback()
|
public void OutboxKpiFailure_ShowsGracefulFallback()
|
||||||
{
|
{
|
||||||
@@ -170,4 +228,16 @@ public class HealthPageTests : BunitContext
|
|||||||
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
|
||||||
|
/// the test's currently-scripted response.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
|
||||||
|
{
|
||||||
|
public ScriptedSiteCallAuditActor(HealthPageTests test)
|
||||||
|
{
|
||||||
|
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22).
|
||||||
|
///
|
||||||
|
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
|
||||||
|
/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all
|
||||||
|
/// route through an injected <see cref="IActorRef"/> (the Site Call Audit proxy),
|
||||||
|
/// so the tests wire a real, lightweight <see cref="ActorSystem"/> with a scripted
|
||||||
|
/// <see cref="ReceiveActor"/> that replies with fixed responses — the same seam
|
||||||
|
/// <c>SetSiteCallAudit</c> exists for. Mirrors <see cref="NotificationReportPageTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<SiteCallSummary>
|
||||||
|
{
|
||||||
|
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<SiteCallQueryRequest> _queryRequests = new();
|
||||||
|
private readonly List<RetrySiteCallRequest> _retryRequests = new();
|
||||||
|
private readonly List<DiscardSiteCallRequest> _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<CommunicationService>.Instance);
|
||||||
|
|
||||||
|
var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
|
||||||
|
_comms.SetSiteCallAudit(auditProxy);
|
||||||
|
|
||||||
|
Services.AddSingleton(_comms);
|
||||||
|
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
|
||||||
|
|
||||||
|
var siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
|
||||||
|
{
|
||||||
|
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<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Page_RequiresDeploymentPolicy()
|
||||||
|
{
|
||||||
|
var attr = typeof(SiteCallsReportPage)
|
||||||
|
.GetCustomAttributes(typeof(AuthorizeAttribute), true)
|
||||||
|
.Cast<AuthorizeAttribute>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
Assert.NotNull(attr);
|
||||||
|
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renders_SiteCallRows()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.Contains("ERP.GetOrder", cut.Markup);
|
||||||
|
Assert.Contains("Historian.Write", cut.Markup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StuckRow_IsBadged()
|
||||||
|
{
|
||||||
|
var cut = Render<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
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<SiteCallSummary>(), null, null);
|
||||||
|
|
||||||
|
var cut = Render<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
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<SiteCallSummary>();
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
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<SiteCallSummary>();
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
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<SiteCallsReportPage>();
|
||||||
|
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<NavigationManager>();
|
||||||
|
nav.NavigateTo("/site-calls/report?status=Parked");
|
||||||
|
|
||||||
|
var cut = Render<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
// The first (and only) query the page issues carries the Parked
|
||||||
|
// status filter — the grid load is pre-filtered, not unfiltered.
|
||||||
|
Assert.Single(_queryRequests);
|
||||||
|
Assert.Equal("Parked", _queryRequests[0].StatusFilter);
|
||||||
|
|
||||||
|
// The Status <select> control reflects the seeded value so the
|
||||||
|
// operator sees the filter and can Clear it.
|
||||||
|
var statusSelect = cut.Find("#sc-status");
|
||||||
|
Assert.Equal("Parked", statusSelect.GetAttribute("value"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
|
||||||
|
{
|
||||||
|
// The Stuck KPI tile emits ?stuck=true.
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
nav.NavigateTo("/site-calls/report?stuck=true");
|
||||||
|
|
||||||
|
var cut = Render<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
// The first query carries StuckOnly = true.
|
||||||
|
Assert.Single(_queryRequests);
|
||||||
|
Assert.True(_queryRequests[0].StuckOnly);
|
||||||
|
|
||||||
|
// The "Stuck only" checkbox is checked.
|
||||||
|
var stuckCheckbox = cut.Find("#sc-stuck-only");
|
||||||
|
Assert.True(stuckCheckbox.HasAttribute("checked"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
|
||||||
|
{
|
||||||
|
// No drill-in params — the page loads exactly as before: an unfiltered
|
||||||
|
// query and no status/stuck filter set on the controls.
|
||||||
|
var cut = Render<SiteCallsReportPage>();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
Assert.Single(_queryRequests);
|
||||||
|
Assert.Null(_queryRequests[0].StatusFilter);
|
||||||
|
Assert.False(_queryRequests[0].StuckOnly);
|
||||||
|
|
||||||
|
var statusSelect = cut.Find("#sc-status");
|
||||||
|
Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
|
||||||
|
var stuckCheckbox = cut.Find("#sc-stuck-only");
|
||||||
|
Assert.False(stuckCheckbox.HasAttribute("checked"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_system.Terminate().Wait(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stand-in for the Site Call Audit actor. Replies to each message type with
|
||||||
|
/// the test's currently-scripted response.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
|
||||||
|
{
|
||||||
|
public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test)
|
||||||
|
{
|
||||||
|
Receive<SiteCallQueryRequest>(r =>
|
||||||
|
{
|
||||||
|
test._queryRequests.Add(r);
|
||||||
|
Sender.Tell(test._queryReply);
|
||||||
|
});
|
||||||
|
Receive<RetrySiteCallRequest>(r =>
|
||||||
|
{
|
||||||
|
test._retryRequests.Add(r);
|
||||||
|
Sender.Tell(test._retryReply);
|
||||||
|
});
|
||||||
|
Receive<DiscardSiteCallRequest>(r =>
|
||||||
|
{
|
||||||
|
test._discardRequests.Add(r);
|
||||||
|
Sender.Tell(test._discardReply);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
||||||
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||||
|
{
|
||||||
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
||||||
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<string?> PromptAsync(
|
||||||
|
string title, string label, string initialValue = "", string? placeholder = null)
|
||||||
|
=> Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ public class AuditLogQueryServiceTests
|
|||||||
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
||||||
{
|
{
|
||||||
var repo = Substitute.For<IAuditLogRepository>();
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
|
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
|
||||||
var paging = new AuditLogPaging(PageSize: 25);
|
var paging = new AuditLogPaging(PageSize: 25);
|
||||||
var expected = new List<AuditEvent>
|
var expected = new List<AuditEvent>
|
||||||
{
|
{
|
||||||
@@ -179,7 +179,7 @@ public class AuditLogQueryServiceTests
|
|||||||
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
||||||
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
|
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
|
||||||
|
|
||||||
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
|
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
|
||||||
|
|
||||||
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
|
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
|
||||||
// fresh DbContext, so this completes cleanly; with a shared scoped context
|
// fresh DbContext, so this completes cleanly; with a shared scoped context
|
||||||
|
|||||||
128
tests/ScadaLink.Commons.Tests/Messages/SiteCallQueriesTests.cs
Normal file
128
tests/ScadaLink.Commons.Tests/Messages/SiteCallQueriesTests.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site Call Audit (#22): construction, value-equality and optionality tests
|
||||||
|
/// for the Site Calls UI query / KPI / detail message contracts. Mirrors the
|
||||||
|
/// Notification Outbox <c>NotificationMessagesTests</c> coverage of the read
|
||||||
|
/// side, scoped to the contracts the Site Calls page consumes.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteCallQueriesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallQueryRequest_PositionalConstruction_SetsAllFields()
|
||||||
|
{
|
||||||
|
var afterCreated = DateTime.UtcNow;
|
||||||
|
var afterId = Guid.NewGuid();
|
||||||
|
var request = new SiteCallQueryRequest(
|
||||||
|
"corr-1", "Parked", "plant-a", "ApiOutbound", "ERP.GetOrder", true,
|
||||||
|
new DateTime(2026, 5, 1), new DateTime(2026, 5, 20), afterCreated, afterId, 50);
|
||||||
|
|
||||||
|
Assert.Equal("corr-1", request.CorrelationId);
|
||||||
|
Assert.Equal("Parked", request.StatusFilter);
|
||||||
|
Assert.Equal("plant-a", request.SourceSiteFilter);
|
||||||
|
Assert.Equal("ApiOutbound", request.ChannelFilter);
|
||||||
|
Assert.Equal("ERP.GetOrder", request.TargetKeyword);
|
||||||
|
Assert.True(request.StuckOnly);
|
||||||
|
Assert.Equal(new DateTime(2026, 5, 1), request.FromUtc);
|
||||||
|
Assert.Equal(new DateTime(2026, 5, 20), request.ToUtc);
|
||||||
|
Assert.Equal(afterCreated, request.AfterCreatedAtUtc);
|
||||||
|
Assert.Equal(afterId, request.AfterId);
|
||||||
|
Assert.Equal(50, request.PageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallQueryRequest_AllowsNullOptionalFilters()
|
||||||
|
{
|
||||||
|
var request = new SiteCallQueryRequest(
|
||||||
|
"corr-2", null, null, null, null, false, null, null, null, null, 25);
|
||||||
|
|
||||||
|
Assert.Null(request.StatusFilter);
|
||||||
|
Assert.Null(request.SourceSiteFilter);
|
||||||
|
Assert.Null(request.ChannelFilter);
|
||||||
|
Assert.Null(request.TargetKeyword);
|
||||||
|
Assert.False(request.StuckOnly);
|
||||||
|
Assert.Null(request.FromUtc);
|
||||||
|
Assert.Null(request.AfterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallQueryResponse_ValueEquality_EqualWhenAllFieldsMatch()
|
||||||
|
{
|
||||||
|
var a = new SiteCallQueryResponse("c", true, null, Array.Empty<SiteCallSummary>(), null, null);
|
||||||
|
var b = new SiteCallQueryResponse("c", true, null, Array.Empty<SiteCallSummary>(), null, null);
|
||||||
|
|
||||||
|
Assert.Equal(a, b);
|
||||||
|
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallSummary_CarriesEntityColumnsAndStuckFlag()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var created = DateTime.UtcNow.AddMinutes(-30);
|
||||||
|
var summary = new SiteCallSummary(
|
||||||
|
id, "plant-a", "DbOutbound", "InventoryDb", "Retrying", 3,
|
||||||
|
"transient 503", 503, created, created.AddMinutes(1), null, IsStuck: true);
|
||||||
|
|
||||||
|
Assert.Equal(id, summary.TrackedOperationId);
|
||||||
|
Assert.Equal("DbOutbound", summary.Channel);
|
||||||
|
Assert.Equal("InventoryDb", summary.Target);
|
||||||
|
Assert.Equal("Retrying", summary.Status);
|
||||||
|
Assert.Equal(3, summary.RetryCount);
|
||||||
|
Assert.Equal(503, summary.HttpStatus);
|
||||||
|
Assert.Null(summary.TerminalAtUtc);
|
||||||
|
Assert.True(summary.IsStuck);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallDetailResponse_MissingRow_HasNullDetail()
|
||||||
|
{
|
||||||
|
var response = new SiteCallDetailResponse("c", false, "site call not found", null);
|
||||||
|
|
||||||
|
Assert.False(response.Success);
|
||||||
|
Assert.Null(response.Detail);
|
||||||
|
Assert.Equal("site call not found", response.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallKpiResponse_FailureShape_ZeroesKpiFields()
|
||||||
|
{
|
||||||
|
var response = new SiteCallKpiResponse(
|
||||||
|
"c", Success: false, ErrorMessage: "db down",
|
||||||
|
BufferedCount: 0, ParkedCount: 0, FailedLastInterval: 0,
|
||||||
|
DeliveredLastInterval: 0, OldestPendingAge: null, StuckCount: 0);
|
||||||
|
|
||||||
|
Assert.False(response.Success);
|
||||||
|
Assert.Equal("db down", response.ErrorMessage);
|
||||||
|
Assert.Equal(0, response.BufferedCount);
|
||||||
|
Assert.Null(response.OldestPendingAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerSiteSiteCallKpiResponse_CarriesPerSiteSnapshots()
|
||||||
|
{
|
||||||
|
var response = new PerSiteSiteCallKpiResponse(
|
||||||
|
"c", true, null,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new SiteCallSiteKpiSnapshot("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(15), 2),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(response.Success);
|
||||||
|
var site = Assert.Single(response.Sites);
|
||||||
|
Assert.Equal("plant-a", site.SourceSite);
|
||||||
|
Assert.Equal(4, site.BufferedCount);
|
||||||
|
Assert.Equal(2, site.StuckCount);
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(15), site.OldestPendingAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallKpiSnapshot_OldestPendingAge_IsNullableForEmptyTable()
|
||||||
|
{
|
||||||
|
var snapshot = new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0);
|
||||||
|
Assert.Null(snapshot.OldestPendingAge);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M8): tests for the shared lax multi-value query-param parsers
|
||||||
|
/// used by the ManagementService + CentralUI audit endpoints and the
|
||||||
|
/// <c>AuditLogPage</c> drill-in parser. The contract under test: parse each
|
||||||
|
/// repeated value independently, silently drop unparseable/blank elements, and
|
||||||
|
/// collapse an empty result to <c>null</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditQueryParamParsersTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_NullInput_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_EmptyInput_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(Array.Empty<string?>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_AllValuesValid_ParsesEverything()
|
||||||
|
{
|
||||||
|
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
|
||||||
|
new[] { "ApiOutbound", "DbOutbound" });
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_IsCaseInsensitive()
|
||||||
|
{
|
||||||
|
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(new[] { "apioutbound" });
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_DropsUnparseableElement_KeepsTheRest()
|
||||||
|
{
|
||||||
|
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
|
||||||
|
new[] { "ApiOutbound", "NotAChannel", "Notification" });
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_AllValuesUnparseable_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditStatus>(new[] { "Bogus", "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseStringList_NullInput_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(AuditQueryParamParsers.ParseStringList(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseStringList_TrimsValuesAndDropsBlanks()
|
||||||
|
{
|
||||||
|
var result = AuditQueryParamParsers.ParseStringList(
|
||||||
|
new[] { " site-1 ", "", " ", "site-2", null });
|
||||||
|
Assert.Equal(new[] { "site-1", "site-2" }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseStringList_AllBlank_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(AuditQueryParamParsers.ParseStringList(new[] { "", " ", null }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ScadaLink.AuditLog.Telemetry;
|
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.Communication.Grpc;
|
using ScadaLink.Communication.Grpc;
|
||||||
|
|
||||||
namespace ScadaLink.AuditLog.Tests.Telemetry;
|
namespace ScadaLink.Communication.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Round-trip + edge tests for the <see cref="AuditEventMapper"/> that bridges
|
/// Round-trip + edge tests for the <see cref="AuditEventDtoMapper"/> that bridges
|
||||||
/// <see cref="AuditEvent"/> (Commons) ↔ <see cref="AuditEventDto"/> (proto).
|
/// <see cref="AuditEvent"/> (Commons) ↔ <see cref="AuditEventDto"/> (proto).
|
||||||
/// ForwardState is site-local and IngestedAtUtc is central-set, so neither survives
|
/// ForwardState is site-local and IngestedAtUtc is central-set, so neither survives
|
||||||
/// the proto round-trip.
|
/// the proto round-trip.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditEventMapperTests
|
public class AuditEventDtoMapperTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields()
|
public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields()
|
||||||
@@ -47,8 +46,8 @@ public class AuditEventMapperTests
|
|||||||
ForwardState = AuditForwardState.Pending
|
ForwardState = AuditForwardState.Pending
|
||||||
};
|
};
|
||||||
|
|
||||||
var dto = AuditEventMapper.ToDto(original);
|
var dto = AuditEventDtoMapper.ToDto(original);
|
||||||
var roundTripped = AuditEventMapper.FromDto(dto);
|
var roundTripped = AuditEventDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
Assert.Equal(original.EventId, roundTripped.EventId);
|
Assert.Equal(original.EventId, roundTripped.EventId);
|
||||||
Assert.Equal(original.OccurredAtUtc, roundTripped.OccurredAtUtc);
|
Assert.Equal(original.OccurredAtUtc, roundTripped.OccurredAtUtc);
|
||||||
@@ -88,7 +87,7 @@ public class AuditEventMapperTests
|
|||||||
// all string? fields left null; CorrelationId null
|
// all string? fields left null; CorrelationId null
|
||||||
};
|
};
|
||||||
|
|
||||||
var dto = AuditEventMapper.ToDto(evt);
|
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, dto.CorrelationId);
|
Assert.Equal(string.Empty, dto.CorrelationId);
|
||||||
Assert.Equal(string.Empty, dto.SourceSiteId);
|
Assert.Equal(string.Empty, dto.SourceSiteId);
|
||||||
@@ -126,7 +125,7 @@ public class AuditEventMapperTests
|
|||||||
Extra = string.Empty
|
Extra = string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
var evt = AuditEventMapper.FromDto(dto);
|
var evt = AuditEventDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
Assert.Null(evt.CorrelationId);
|
Assert.Null(evt.CorrelationId);
|
||||||
Assert.Null(evt.SourceSiteId);
|
Assert.Null(evt.SourceSiteId);
|
||||||
@@ -154,8 +153,8 @@ public class AuditEventMapperTests
|
|||||||
Status = AuditStatus.Delivered
|
Status = AuditStatus.Delivered
|
||||||
};
|
};
|
||||||
|
|
||||||
var dto = AuditEventMapper.ToDto(evt);
|
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||||
var roundTripped = AuditEventMapper.FromDto(dto);
|
var roundTripped = AuditEventDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
Assert.Equal(DateTimeKind.Utc, roundTripped.OccurredAtUtc.Kind);
|
Assert.Equal(DateTimeKind.Utc, roundTripped.OccurredAtUtc.Kind);
|
||||||
Assert.Equal(occurredAt, roundTripped.OccurredAtUtc);
|
Assert.Equal(occurredAt, roundTripped.OccurredAtUtc);
|
||||||
@@ -175,7 +174,7 @@ public class AuditEventMapperTests
|
|||||||
DurationMs = null
|
DurationMs = null
|
||||||
};
|
};
|
||||||
|
|
||||||
var dto = AuditEventMapper.ToDto(evt);
|
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||||
|
|
||||||
Assert.Null(dto.HttpStatus);
|
Assert.Null(dto.HttpStatus);
|
||||||
Assert.Null(dto.DurationMs);
|
Assert.Null(dto.DurationMs);
|
||||||
@@ -197,7 +196,7 @@ public class AuditEventMapperTests
|
|||||||
Assert.Null(dto.HttpStatus);
|
Assert.Null(dto.HttpStatus);
|
||||||
Assert.Null(dto.DurationMs);
|
Assert.Null(dto.DurationMs);
|
||||||
|
|
||||||
var evt = AuditEventMapper.FromDto(dto);
|
var evt = AuditEventDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
Assert.Null(evt.HttpStatus);
|
Assert.Null(evt.HttpStatus);
|
||||||
Assert.Null(evt.DurationMs);
|
Assert.Null(evt.DurationMs);
|
||||||
@@ -215,7 +214,7 @@ public class AuditEventMapperTests
|
|||||||
Status = AuditStatus.Parked
|
Status = AuditStatus.Parked
|
||||||
};
|
};
|
||||||
|
|
||||||
var dto = AuditEventMapper.ToDto(evt);
|
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||||
|
|
||||||
Assert.Equal("ApiOutbound", dto.Channel);
|
Assert.Equal("ApiOutbound", dto.Channel);
|
||||||
Assert.Equal("ApiCallCached", dto.Kind);
|
Assert.Equal("ApiCallCached", dto.Kind);
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Communication.Actors;
|
||||||
|
|
||||||
|
namespace ScadaLink.Communication.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the Audit Log (#23) site→central ClusterClient ingest routing on
|
||||||
|
/// <see cref="CentralCommunicationActor"/>. A site ClusterClient delivers
|
||||||
|
/// <see cref="IngestAuditEventsCommand"/> / <see cref="IngestCachedTelemetryCommand"/>
|
||||||
|
/// to the receptionist-registered actor, which forwards to the registered
|
||||||
|
/// <c>AuditLogIngestActor</c> proxy and routes the reply back to the site.
|
||||||
|
/// Mirrors the NotificationSubmit / RegisterNotificationOutbox pattern.
|
||||||
|
/// </summary>
|
||||||
|
public class CentralCommunicationActorAuditTests : TestKit
|
||||||
|
{
|
||||||
|
public CentralCommunicationActorAuditTests() : base(@"akka.loglevel = DEBUG") { }
|
||||||
|
|
||||||
|
private IActorRef CreateActor(TimeSpan? auditIngestAskTimeout = null)
|
||||||
|
{
|
||||||
|
var mockRepo = Substitute.For<ISiteRepository>();
|
||||||
|
mockRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Commons.Entities.Sites.Site>());
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => mockRepo);
|
||||||
|
var sp = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var mockFactory = Substitute.For<ISiteClientFactory>();
|
||||||
|
return Sys.ActorOf(Props.Create(() =>
|
||||||
|
new CentralCommunicationActor(sp, mockFactory, auditIngestAskTimeout)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditEvent SampleAuditEvent() => new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = AuditKind.ApiCall,
|
||||||
|
Status = AuditStatus.Delivered,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SiteCall SampleSiteCall() => new()
|
||||||
|
{
|
||||||
|
TrackedOperationId = TrackedOperationId.New(),
|
||||||
|
Channel = "OutboundApi",
|
||||||
|
Target = "ExternalSystemA",
|
||||||
|
SourceSite = "site1",
|
||||||
|
Status = "Delivered",
|
||||||
|
RetryCount = 0,
|
||||||
|
CreatedAtUtc = DateTime.UtcNow,
|
||||||
|
UpdatedAtUtc = DateTime.UtcNow,
|
||||||
|
IngestedAtUtc = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestAuditEventsCommand_WithRegisteredProxy_ForwardsAndRoutesReplyToSender()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
var auditProbe = CreateTestProbe();
|
||||||
|
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
|
||||||
|
|
||||||
|
var evt = SampleAuditEvent();
|
||||||
|
var cmd = new IngestAuditEventsCommand(new[] { evt });
|
||||||
|
actor.Tell(cmd);
|
||||||
|
|
||||||
|
// The audit-ingest proxy receives the command, with the original site
|
||||||
|
// sender preserved (Forward semantics).
|
||||||
|
auditProbe.ExpectMsg(cmd);
|
||||||
|
|
||||||
|
// When the proxy replies, the actor routes it back to the original sender.
|
||||||
|
var reply = new IngestAuditEventsReply(new[] { evt.EventId });
|
||||||
|
auditProbe.Reply(reply);
|
||||||
|
|
||||||
|
var received = ExpectMsg<IngestAuditEventsReply>();
|
||||||
|
Assert.Equal(new[] { evt.EventId }, received.AcceptedEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestAuditEventsCommand_WithNoProxyRegistered_RepliesEmptyAcceptedEventIds()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
|
||||||
|
actor.Tell(new IngestAuditEventsCommand(new[] { SampleAuditEvent() }));
|
||||||
|
|
||||||
|
var reply = ExpectMsg<IngestAuditEventsReply>();
|
||||||
|
Assert.Empty(reply.AcceptedEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestAuditEventsCommand_WhenProxyNeverReplies_PipesStatusFailureToSender()
|
||||||
|
{
|
||||||
|
// A short test-only Ask timeout (constructor seam) keeps the test fast —
|
||||||
|
// production uses the 30 s default.
|
||||||
|
var actor = CreateActor(auditIngestAskTimeout: TimeSpan.FromMilliseconds(200));
|
||||||
|
var auditProbe = CreateTestProbe();
|
||||||
|
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
|
||||||
|
|
||||||
|
var cmd = new IngestAuditEventsCommand(new[] { SampleAuditEvent() });
|
||||||
|
actor.Tell(cmd);
|
||||||
|
|
||||||
|
// The proxy receives the command but deliberately never replies.
|
||||||
|
auditProbe.ExpectMsg(cmd);
|
||||||
|
|
||||||
|
// The Ask times out; PipeTo forwards the faulted task as a Status.Failure
|
||||||
|
// to the original sender. This is the real transient signal the site's
|
||||||
|
// own Ask faults on — it is NOT swallowed into an empty ack.
|
||||||
|
var failure = ExpectMsg<Status.Failure>();
|
||||||
|
Assert.IsType<AskTimeoutException>(failure.Cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestCachedTelemetryCommand_WithRegisteredProxy_ForwardsAndRoutesReplyToSender()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
var auditProbe = CreateTestProbe();
|
||||||
|
actor.Tell(new RegisterAuditIngest(auditProbe.Ref));
|
||||||
|
|
||||||
|
var entry = new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall());
|
||||||
|
var cmd = new IngestCachedTelemetryCommand(new[] { entry });
|
||||||
|
actor.Tell(cmd);
|
||||||
|
|
||||||
|
auditProbe.ExpectMsg(cmd);
|
||||||
|
|
||||||
|
var reply = new IngestCachedTelemetryReply(new[] { entry.Audit.EventId });
|
||||||
|
auditProbe.Reply(reply);
|
||||||
|
|
||||||
|
var received = ExpectMsg<IngestCachedTelemetryReply>();
|
||||||
|
Assert.Equal(new[] { entry.Audit.EventId }, received.AcceptedEventIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IngestCachedTelemetryCommand_WithNoProxyRegistered_RepliesEmptyAcceptedEventIds()
|
||||||
|
{
|
||||||
|
var actor = CreateActor();
|
||||||
|
|
||||||
|
var entry = new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall());
|
||||||
|
actor.Tell(new IngestCachedTelemetryCommand(new[] { entry }));
|
||||||
|
|
||||||
|
var reply = ExpectMsg<IngestCachedTelemetryReply>();
|
||||||
|
Assert.Empty(reply.AcceptedEventIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ using Akka.Actor;
|
|||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.Commons.Messages.Audit;
|
||||||
using ScadaLink.Commons.Messages.Deployment;
|
using ScadaLink.Commons.Messages.Deployment;
|
||||||
using ScadaLink.Commons.Messages.Notification;
|
using ScadaLink.Commons.Messages.Notification;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
using ScadaLink.Commons.Types.Notifications;
|
using ScadaLink.Commons.Types.Notifications;
|
||||||
|
|
||||||
namespace ScadaLink.Communication.Tests;
|
namespace ScadaLink.Communication.Tests;
|
||||||
@@ -236,6 +238,208 @@ public class CommunicationServiceTests : TestKit
|
|||||||
Assert.Equal("plant-a", result.Sites[0].SourceSiteId);
|
Assert.Equal("plant-a", result.Sites[0].SourceSiteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Site Call Audit: central-side audit actor calls ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QuerySiteCallsAsync_BeforeSiteCallAuditSet_Throws()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
service.QuerySiteCallsAsync(new SiteCallQueryRequest(
|
||||||
|
"corr-1", null, null, null, null, false, null, null, null, null, 50)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
service.GetSiteCallKpisAsync(new SiteCallKpiRequest("corr-1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSiteCallDetailAsync_BeforeSiteCallAuditSet_Throws()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
service.GetSiteCallDetailAsync(new SiteCallDetailRequest("corr-1", Guid.NewGuid())));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPerSiteSiteCallKpisAsync_BeforeSiteCallAuditSet_Throws()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
service.GetPerSiteSiteCallKpisAsync(new PerSiteSiteCallKpiRequest("corr-1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QuerySiteCallsAsync_AsksSiteCallAuditProxyDirectly()
|
||||||
|
{
|
||||||
|
// The Site Call Audit actor is central-local: the request must be Asked
|
||||||
|
// directly to its proxy (no SiteEnvelope wrapping).
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
service.SetSiteCallAudit(probe.Ref);
|
||||||
|
|
||||||
|
var request = new SiteCallQueryRequest(
|
||||||
|
"corr-q", "Parked", "plant-a", "ApiOutbound", "ERP.GetOrder", true,
|
||||||
|
null, null, null, null, 25);
|
||||||
|
var task = service.QuerySiteCallsAsync(request);
|
||||||
|
|
||||||
|
var received = probe.ExpectMsg<SiteCallQueryRequest>();
|
||||||
|
Assert.Same(request, received);
|
||||||
|
var reply = new SiteCallQueryResponse(
|
||||||
|
"corr-q", true, null, Array.Empty<SiteCallSummary>(), null, null);
|
||||||
|
probe.Reply(reply);
|
||||||
|
|
||||||
|
Assert.Same(reply, await task);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSiteCallDetailAsync_AsksSiteCallAuditProxyDirectly()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
service.SetSiteCallAudit(probe.Ref);
|
||||||
|
|
||||||
|
var request = new SiteCallDetailRequest("corr-d", Guid.NewGuid());
|
||||||
|
var task = service.GetSiteCallDetailAsync(request);
|
||||||
|
|
||||||
|
var received = probe.ExpectMsg<SiteCallDetailRequest>();
|
||||||
|
Assert.Same(request, received);
|
||||||
|
var reply = new SiteCallDetailResponse("corr-d", false, "site call not found", null);
|
||||||
|
probe.Reply(reply);
|
||||||
|
|
||||||
|
var result = await task;
|
||||||
|
Assert.Same(reply, result);
|
||||||
|
Assert.False(result.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
service.SetSiteCallAudit(probe.Ref);
|
||||||
|
|
||||||
|
var request = new SiteCallKpiRequest("corr-k");
|
||||||
|
var task = service.GetSiteCallKpisAsync(request);
|
||||||
|
|
||||||
|
var received = probe.ExpectMsg<SiteCallKpiRequest>();
|
||||||
|
Assert.Same(request, received);
|
||||||
|
var reply = new SiteCallKpiResponse(
|
||||||
|
"corr-k", true, null, 4, 1, 2, 9, TimeSpan.FromMinutes(7), 1);
|
||||||
|
probe.Reply(reply);
|
||||||
|
|
||||||
|
var result = await task;
|
||||||
|
Assert.Same(reply, result);
|
||||||
|
Assert.Equal(4, result.BufferedCount);
|
||||||
|
Assert.Equal(1, result.StuckCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPerSiteSiteCallKpisAsync_AsksSiteCallAuditProxyDirectly()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
service.SetSiteCallAudit(probe.Ref);
|
||||||
|
|
||||||
|
var request = new PerSiteSiteCallKpiRequest("corr-ps");
|
||||||
|
var task = service.GetPerSiteSiteCallKpisAsync(request);
|
||||||
|
|
||||||
|
var received = probe.ExpectMsg<PerSiteSiteCallKpiRequest>();
|
||||||
|
Assert.Same(request, received);
|
||||||
|
var reply = new PerSiteSiteCallKpiResponse(
|
||||||
|
"corr-ps", true, null,
|
||||||
|
new[] { new SiteCallSiteKpiSnapshot("plant-a", 3, 0, 0, 5, null, 0) });
|
||||||
|
probe.Reply(reply);
|
||||||
|
|
||||||
|
var result = await task;
|
||||||
|
Assert.Same(reply, result);
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Single(result.Sites);
|
||||||
|
Assert.Equal("plant-a", result.Sites[0].SourceSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RetrySiteCallAsync_BeforeSiteCallAuditSet_Throws()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
service.RetrySiteCallAsync(new RetrySiteCallRequest("corr-1", Guid.NewGuid(), "plant-a")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RetrySiteCallAsync_AsksSiteCallAuditProxyDirectly()
|
||||||
|
{
|
||||||
|
// The relay is initiated by Asking the central-local Site Call Audit
|
||||||
|
// proxy directly (no SiteEnvelope wrapping at this layer — the actor
|
||||||
|
// does the site routing itself).
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
service.SetSiteCallAudit(probe.Ref);
|
||||||
|
|
||||||
|
var request = new RetrySiteCallRequest("corr-r", Guid.NewGuid(), "plant-a");
|
||||||
|
var task = service.RetrySiteCallAsync(request);
|
||||||
|
|
||||||
|
var received = probe.ExpectMsg<RetrySiteCallRequest>();
|
||||||
|
Assert.Same(request, received);
|
||||||
|
var reply = new RetrySiteCallResponse(
|
||||||
|
"corr-r", SiteCallRelayOutcome.Applied, true, true, null);
|
||||||
|
probe.Reply(reply);
|
||||||
|
|
||||||
|
Assert.Same(reply, await task);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardSiteCallAsync_AsksSiteCallAuditProxyDirectly()
|
||||||
|
{
|
||||||
|
var service = new CommunicationService(
|
||||||
|
Options.Create(new CommunicationOptions()),
|
||||||
|
NullLogger<CommunicationService>.Instance);
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
service.SetSiteCallAudit(probe.Ref);
|
||||||
|
|
||||||
|
var request = new DiscardSiteCallRequest("corr-d", Guid.NewGuid(), "plant-a");
|
||||||
|
var task = service.DiscardSiteCallAsync(request);
|
||||||
|
|
||||||
|
var received = probe.ExpectMsg<DiscardSiteCallRequest>();
|
||||||
|
Assert.Same(request, received);
|
||||||
|
var reply = new DiscardSiteCallResponse(
|
||||||
|
"corr-d", SiteCallRelayOutcome.SiteUnreachable, false, false, "unreachable");
|
||||||
|
probe.Reply(reply);
|
||||||
|
|
||||||
|
var result = await task;
|
||||||
|
Assert.Same(reply, result);
|
||||||
|
Assert.False(result.SiteReachable);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stand-in for CentralCommunicationActor: verifies the message is wrapped
|
/// Stand-in for CentralCommunicationActor: verifies the message is wrapped
|
||||||
/// in a SiteEnvelope targeting the requested site and replies with a typed
|
/// in a SiteEnvelope targeting the requested site and replies with a typed
|
||||||
|
|||||||
135
tests/ScadaLink.Communication.Tests/SiteCallDtoMapperTests.cs
Normal file
135
tests/ScadaLink.Communication.Tests/SiteCallDtoMapperTests.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using ScadaLink.Communication.Grpc;
|
||||||
|
|
||||||
|
namespace ScadaLink.Communication.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Field-coverage + edge tests for the <see cref="SiteCallDtoMapper"/> that
|
||||||
|
/// decodes <see cref="SiteCallOperationalDto"/> (proto) into the
|
||||||
|
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> persistence entity.
|
||||||
|
/// Only the DTO→entity direction exists — nothing in the system maps a
|
||||||
|
/// <c>SiteCall</c> back onto the wire — so there is no round-trip test.
|
||||||
|
/// <c>IngestedAtUtc</c> is a site-side placeholder the central ingest actor
|
||||||
|
/// overwrites, so it is asserted as "recent UTC" rather than a fixed value.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteCallDtoMapperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_FullyPopulated_MapsEveryField()
|
||||||
|
{
|
||||||
|
var trackedOperationId = Guid.NewGuid();
|
||||||
|
var createdAt = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var updatedAt = new DateTime(2026, 5, 20, 10, 5, 0, DateTimeKind.Utc);
|
||||||
|
var terminalAt = new DateTime(2026, 5, 20, 10, 10, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var dto = new SiteCallOperationalDto
|
||||||
|
{
|
||||||
|
TrackedOperationId = trackedOperationId.ToString(),
|
||||||
|
Channel = "ApiOutbound",
|
||||||
|
Target = "ERP.GetOrder",
|
||||||
|
SourceSite = "site-melbourne",
|
||||||
|
Status = "Delivered",
|
||||||
|
RetryCount = 3,
|
||||||
|
LastError = "transient 503",
|
||||||
|
HttpStatus = 200,
|
||||||
|
CreatedAtUtc = Timestamp.FromDateTime(createdAt),
|
||||||
|
UpdatedAtUtc = Timestamp.FromDateTime(updatedAt),
|
||||||
|
TerminalAtUtc = Timestamp.FromDateTime(terminalAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
|
Assert.Equal(trackedOperationId, entity.TrackedOperationId.Value);
|
||||||
|
Assert.Equal("ApiOutbound", entity.Channel);
|
||||||
|
Assert.Equal("ERP.GetOrder", entity.Target);
|
||||||
|
Assert.Equal("site-melbourne", entity.SourceSite);
|
||||||
|
Assert.Equal("Delivered", entity.Status);
|
||||||
|
Assert.Equal(3, entity.RetryCount);
|
||||||
|
Assert.Equal("transient 503", entity.LastError);
|
||||||
|
Assert.Equal(200, entity.HttpStatus);
|
||||||
|
Assert.Equal(createdAt, entity.CreatedAtUtc);
|
||||||
|
Assert.Equal(updatedAt, entity.UpdatedAtUtc);
|
||||||
|
Assert.Equal(terminalAt, entity.TerminalAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_EmptyLastError_BecomesNull()
|
||||||
|
{
|
||||||
|
var dto = NewMinimalDto();
|
||||||
|
dto.LastError = string.Empty;
|
||||||
|
|
||||||
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
|
Assert.Null(entity.LastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_AbsentHttpStatus_StaysNull()
|
||||||
|
{
|
||||||
|
// Int32Value wrapper unset on the wire — preserves true null semantics
|
||||||
|
// for non-API cached writes.
|
||||||
|
var dto = NewMinimalDto();
|
||||||
|
|
||||||
|
Assert.Null(dto.HttpStatus);
|
||||||
|
|
||||||
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
|
Assert.Null(entity.HttpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_AbsentTerminalAt_StaysNull()
|
||||||
|
{
|
||||||
|
// Timestamp wrapper unset while the call is still active.
|
||||||
|
var dto = NewMinimalDto();
|
||||||
|
|
||||||
|
Assert.Null(dto.TerminalAtUtc);
|
||||||
|
|
||||||
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
|
Assert.Null(entity.TerminalAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_Timestamps_RehydrateAsUtcKind()
|
||||||
|
{
|
||||||
|
var dto = NewMinimalDto();
|
||||||
|
|
||||||
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
|
Assert.Equal(DateTimeKind.Utc, entity.CreatedAtUtc.Kind);
|
||||||
|
Assert.Equal(DateTimeKind.Utc, entity.UpdatedAtUtc.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_IngestedAtUtc_StampedAsRecentPlaceholder()
|
||||||
|
{
|
||||||
|
// IngestedAtUtc is a site-side DateTime.UtcNow placeholder; the central
|
||||||
|
// ingest actor overwrites it inside the dual-write transaction.
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var entity = SiteCallDtoMapper.FromDto(NewMinimalDto());
|
||||||
|
|
||||||
|
var after = DateTime.UtcNow;
|
||||||
|
Assert.InRange(entity.IngestedAtUtc, before, after);
|
||||||
|
Assert.Equal(DateTimeKind.Utc, entity.IngestedAtUtc.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromDto_Null_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => SiteCallDtoMapper.FromDto(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteCallOperationalDto NewMinimalDto() => new()
|
||||||
|
{
|
||||||
|
TrackedOperationId = Guid.NewGuid().ToString(),
|
||||||
|
Channel = "DbOutbound",
|
||||||
|
Target = "warehouse.dbo.WriteOrder",
|
||||||
|
SourceSite = "site-brisbane",
|
||||||
|
Status = "Submitted",
|
||||||
|
RetryCount = 0,
|
||||||
|
CreatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||||
|
UpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -214,4 +214,72 @@ public class SiteCommunicationActorTests : TestKit
|
|||||||
|
|
||||||
ExpectMsg<EventLogQueryResponse>(msg => !msg.Success);
|
ExpectMsg<EventLogQueryResponse>(msg => !msg.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Task 5 (#22): central→site Retry/Discard relay for parked cached calls ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetryParkedOperation_WithHandler_ForwardedToParkedMessageHandler()
|
||||||
|
{
|
||||||
|
var dmProbe = CreateTestProbe();
|
||||||
|
var handlerProbe = CreateTestProbe();
|
||||||
|
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||||
|
|
||||||
|
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.ParkedMessages, handlerProbe.Ref));
|
||||||
|
|
||||||
|
var id = Commons.Types.TrackedOperationId.New();
|
||||||
|
siteActor.Tell(new RetryParkedOperation("corr-rp", id));
|
||||||
|
|
||||||
|
handlerProbe.ExpectMsg<RetryParkedOperation>(msg =>
|
||||||
|
msg.CorrelationId == "corr-rp" && msg.TrackedOperationId.Equals(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscardParkedOperation_WithHandler_ForwardedToParkedMessageHandler()
|
||||||
|
{
|
||||||
|
var dmProbe = CreateTestProbe();
|
||||||
|
var handlerProbe = CreateTestProbe();
|
||||||
|
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||||
|
|
||||||
|
siteActor.Tell(new RegisterLocalHandler(LocalHandlerType.ParkedMessages, handlerProbe.Ref));
|
||||||
|
|
||||||
|
var id = Commons.Types.TrackedOperationId.New();
|
||||||
|
siteActor.Tell(new DiscardParkedOperation("corr-dp", id));
|
||||||
|
|
||||||
|
handlerProbe.ExpectMsg<DiscardParkedOperation>(msg =>
|
||||||
|
msg.CorrelationId == "corr-dp" && msg.TrackedOperationId.Equals(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetryParkedOperation_WithoutHandler_RepliesNotAppliedAck()
|
||||||
|
{
|
||||||
|
// No parked-message handler registered — the relay must get a definitive
|
||||||
|
// non-applied ack, not silence (the SiteCallAuditActor's Ask must not
|
||||||
|
// hang and then mis-report site-unreachable when the site IS reachable).
|
||||||
|
var dmProbe = CreateTestProbe();
|
||||||
|
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||||
|
|
||||||
|
siteActor.Tell(new RetryParkedOperation("corr-no-handler", Commons.Types.TrackedOperationId.New()));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||||
|
Assert.Equal("corr-no-handler", ack.CorrelationId);
|
||||||
|
Assert.False(ack.Applied);
|
||||||
|
Assert.NotNull(ack.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscardParkedOperation_WithoutHandler_RepliesNotAppliedAck()
|
||||||
|
{
|
||||||
|
var dmProbe = CreateTestProbe();
|
||||||
|
var siteActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new SiteCommunicationActor("site1", _options, dmProbe.Ref)));
|
||||||
|
|
||||||
|
siteActor.Tell(new DiscardParkedOperation("corr-no-handler", Commons.Types.TrackedOperationId.New()));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||||
|
Assert.False(ack.Applied);
|
||||||
|
Assert.NotNull(ack.ErrorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
|
||||||
|
|
||||||
var rows = await repo.QueryAsync(
|
var rows = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
Assert.Equal(3, rows.Count);
|
Assert.Equal(3, rows.Count);
|
||||||
@@ -114,13 +114,116 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
|
||||||
|
|
||||||
var rows = await repo.QueryAsync(
|
var rows = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId),
|
new AuditLogQueryFilter(
|
||||||
|
Channels: new[] { AuditChannel.Notification },
|
||||||
|
SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
Assert.Equal(2, rows.Count);
|
Assert.Equal(2, rows.Count);
|
||||||
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
|
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_FilterByMultipleChannels_ReturnsUnion()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 2, 14, 0, 0, DateTimeKind.Utc);
|
||||||
|
// One row per channel; the multi-value filter must return the union of
|
||||||
|
// ApiOutbound + Notification and exclude DbOutbound.
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.DbOutbound));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(
|
||||||
|
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.Notification },
|
||||||
|
SourceSiteIds: new[] { siteId }),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.All(rows, r => Assert.Contains(r.Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
|
||||||
|
Assert.DoesNotContain(rows, r => r.Channel == AuditChannel.DbOutbound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_FilterByMultipleStatuses_ReturnsUnion()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 2, 15, 0, 0, DateTimeKind.Utc);
|
||||||
|
// Failed + Parked are requested; Delivered must be excluded.
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, status: AuditStatus.Failed));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), status: AuditStatus.Parked));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), status: AuditStatus.Delivered));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(
|
||||||
|
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
|
||||||
|
SourceSiteIds: new[] { siteId }),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.All(rows, r => Assert.Contains(r.Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
|
||||||
|
Assert.DoesNotContain(rows, r => r.Status == AuditStatus.Delivered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_FilterByMultipleSourceSiteIds_ReturnsUnion()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteA = NewSiteId();
|
||||||
|
var siteB = NewSiteId();
|
||||||
|
var siteC = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 2, 16, 0, 0, DateTimeKind.Utc);
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteA, occurredAtUtc: t0));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteB, occurredAtUtc: t0.AddMinutes(1)));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteC, occurredAtUtc: t0.AddMinutes(2)));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteA, siteB }),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.All(rows, r => Assert.Contains(r.SourceSiteId, new[] { siteA, siteB }));
|
||||||
|
Assert.DoesNotContain(rows, r => r.SourceSiteId == siteC);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_EmptyChannelList_DoesNotConstrain()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 2, 17, 0, 0, DateTimeKind.Utc);
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
|
||||||
|
|
||||||
|
// An empty Channels list must mean "no filter" — NOT WHERE 1=0.
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(
|
||||||
|
Channels: Array.Empty<AuditChannel>(),
|
||||||
|
SourceSiteIds: new[] { siteId }),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task QueryAsync_FilterBySourceSiteId()
|
public async Task QueryAsync_FilterBySourceSiteId()
|
||||||
{
|
{
|
||||||
@@ -137,7 +240,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
|
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
|
||||||
|
|
||||||
var rows = await repo.QueryAsync(
|
var rows = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
Assert.Equal(2, rows.Count);
|
Assert.Equal(2, rows.Count);
|
||||||
@@ -160,7 +263,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
|
|
||||||
var rows = await repo.QueryAsync(
|
var rows = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(
|
new AuditLogQueryFilter(
|
||||||
SourceSiteId: siteId,
|
SourceSiteIds: new[] { siteId },
|
||||||
FromUtc: t0.AddMinutes(10),
|
FromUtc: t0.AddMinutes(10),
|
||||||
ToUtc: t0.AddHours(1)),
|
ToUtc: t0.AddHours(1)),
|
||||||
new AuditLogPaging(PageSize: 10));
|
new AuditLogPaging(PageSize: 10));
|
||||||
@@ -187,7 +290,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
}
|
}
|
||||||
|
|
||||||
var page1 = await repo.QueryAsync(
|
var page1 = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(PageSize: 2));
|
new AuditLogPaging(PageSize: 2));
|
||||||
|
|
||||||
Assert.Equal(2, page1.Count);
|
Assert.Equal(2, page1.Count);
|
||||||
@@ -196,7 +299,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
|
|
||||||
var cursor = page1[^1];
|
var cursor = page1[^1];
|
||||||
var page2 = await repo.QueryAsync(
|
var page2 = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(
|
new AuditLogPaging(
|
||||||
PageSize: 2,
|
PageSize: 2,
|
||||||
AfterOccurredAtUtc: cursor.OccurredAtUtc,
|
AfterOccurredAtUtc: cursor.OccurredAtUtc,
|
||||||
@@ -208,7 +311,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
|
|
||||||
var cursor2 = page2[^1];
|
var cursor2 = page2[^1];
|
||||||
var page3 = await repo.QueryAsync(
|
var page3 = await repo.QueryAsync(
|
||||||
new AuditLogQueryFilter(SourceSiteId: siteId),
|
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||||
new AuditLogPaging(
|
new AuditLogPaging(
|
||||||
PageSize: 2,
|
PageSize: 2,
|
||||||
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
|
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
|
||||||
@@ -281,7 +384,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
await repo.InsertIfNotExistsAsync(e);
|
await repo.InsertIfNotExistsAsync(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
var filter = new AuditLogQueryFilter(SourceSiteId: siteId);
|
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { siteId });
|
||||||
|
|
||||||
var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2));
|
var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2));
|
||||||
Assert.Equal(2, page1.Count);
|
Assert.Equal(2, page1.Count);
|
||||||
|
|||||||
@@ -271,6 +271,67 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.Equal(5, allIds.Count);
|
Assert.Equal(5, allIds.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var site = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
|
||||||
|
// Three stuck rows (non-terminal, created before the cutoff) interleaved
|
||||||
|
// by CreatedAtUtc with non-stuck rows: recent non-terminal rows and an
|
||||||
|
// old-but-terminal row. The stuck predicate is pushed into the SQL WHERE
|
||||||
|
// alongside the keyset cursor, so each page must come back full of stuck
|
||||||
|
// rows — never under-filled by a post-filter.
|
||||||
|
var t0 = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
var cutoff = t0.AddMinutes(10);
|
||||||
|
|
||||||
|
var stuckIds = new List<TrackedOperationId>();
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var stuckId = TrackedOperationId.New();
|
||||||
|
stuckIds.Add(stuckId);
|
||||||
|
// Stuck: non-terminal, created before the cutoff.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
stuckId, sourceSite: site, status: "Attempted",
|
||||||
|
createdAtUtc: t0.AddMinutes(i)));
|
||||||
|
// Not stuck: non-terminal but created after the cutoff.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), sourceSite: site, status: "Attempted",
|
||||||
|
createdAtUtc: cutoff.AddMinutes(i + 1)));
|
||||||
|
// Not stuck: created before the cutoff but terminal.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), sourceSite: site, status: "Delivered",
|
||||||
|
createdAtUtc: t0.AddMinutes(i), terminal: true,
|
||||||
|
terminalAtUtc: t0.AddMinutes(i + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter = new SiteCallQueryFilter(SourceSite: site, StuckCutoffUtc: cutoff);
|
||||||
|
|
||||||
|
var page1 = await repo.QueryAsync(filter, new SiteCallPaging(PageSize: 2));
|
||||||
|
Assert.Equal(2, page1.Count);
|
||||||
|
Assert.All(page1, r => Assert.Null(r.TerminalAtUtc));
|
||||||
|
Assert.All(page1, r => Assert.True(r.CreatedAtUtc < cutoff));
|
||||||
|
|
||||||
|
var cursor1 = page1[^1];
|
||||||
|
var page2 = await repo.QueryAsync(
|
||||||
|
filter,
|
||||||
|
new SiteCallPaging(
|
||||||
|
PageSize: 2,
|
||||||
|
AfterCreatedAtUtc: cursor1.CreatedAtUtc,
|
||||||
|
AfterId: cursor1.TrackedOperationId));
|
||||||
|
// Only the third stuck row remains — no empty trailing page.
|
||||||
|
Assert.Single(page2);
|
||||||
|
Assert.Null(page2[0].TerminalAtUtc);
|
||||||
|
Assert.True(page2[0].CreatedAtUtc < cutoff);
|
||||||
|
|
||||||
|
// Exactly the three stuck rows, no overlap, no non-stuck leakage.
|
||||||
|
var returned = page1.Concat(page2).Select(r => r.TrackedOperationId).ToHashSet();
|
||||||
|
Assert.Equal(stuckIds.ToHashSet(), returned);
|
||||||
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
|
public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
|
||||||
{
|
{
|
||||||
@@ -338,6 +399,104 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.NotNull(await repo.GetAsync(recentTerminalId));
|
Assert.NotNull(await repo.GetAsync(recentTerminalId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- KPI snapshot tests -------------------------------------------------
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task ComputeKpisAsync_CountsBufferedParkedFailedDeliveredAndStuck()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var site = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var stuckCutoff = now.AddMinutes(-10);
|
||||||
|
var intervalSince = now.AddHours(-1);
|
||||||
|
|
||||||
|
// Buffered + stuck (non-terminal Attempted, created 30 min ago).
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||||
|
// Buffered but NOT stuck (non-terminal Attempted, created 2 min ago).
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||||
|
// Parked (terminal).
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), site, status: "Parked",
|
||||||
|
createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4),
|
||||||
|
terminal: true, terminalAtUtc: now.AddMinutes(-4)));
|
||||||
|
// Delivered within the interval.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), site, status: "Delivered",
|
||||||
|
createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
|
||||||
|
terminal: true, terminalAtUtc: now.AddMinutes(-1)));
|
||||||
|
// Failed within the interval.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), site, status: "Failed",
|
||||||
|
createdAtUtc: now.AddMinutes(-6), updatedAtUtc: now.AddMinutes(-2),
|
||||||
|
terminal: true, terminalAtUtc: now.AddMinutes(-2)));
|
||||||
|
// Delivered OUTSIDE the interval (2 hours ago) — must not count.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), site, status: "Delivered",
|
||||||
|
createdAtUtc: now.AddHours(-3), updatedAtUtc: now.AddHours(-2),
|
||||||
|
terminal: true, terminalAtUtc: now.AddHours(-2)));
|
||||||
|
|
||||||
|
var snapshot = await repo.ComputeKpisAsync(stuckCutoff, intervalSince);
|
||||||
|
|
||||||
|
// Counts are global; assert the floor since the table is shared with
|
||||||
|
// other tests. The OUTSIDE-interval Delivered row proves the window
|
||||||
|
// bounds the throughput counts.
|
||||||
|
Assert.True(snapshot.BufferedCount >= 2);
|
||||||
|
Assert.True(snapshot.ParkedCount >= 1);
|
||||||
|
Assert.True(snapshot.StuckCount >= 1);
|
||||||
|
Assert.True(snapshot.DeliveredLastInterval >= 1);
|
||||||
|
Assert.True(snapshot.FailedLastInterval >= 1);
|
||||||
|
Assert.NotNull(snapshot.OldestPendingAge);
|
||||||
|
Assert.True(snapshot.OldestPendingAge >= TimeSpan.FromMinutes(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task ComputePerSiteKpisAsync_ScopesCountsToEachSite()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteA = NewSiteId();
|
||||||
|
var siteB = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var stuckCutoff = now.AddMinutes(-10);
|
||||||
|
var intervalSince = now.AddHours(-1);
|
||||||
|
|
||||||
|
// siteA: 2 buffered (one stuck), 1 parked.
|
||||||
|
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||||
|
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteA, status: "Parked",
|
||||||
|
createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4),
|
||||||
|
terminal: true, terminalAtUtc: now.AddMinutes(-4)));
|
||||||
|
// siteB: 1 delivered within interval only.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteB, status: "Delivered",
|
||||||
|
createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
|
||||||
|
terminal: true, terminalAtUtc: now.AddMinutes(-1)));
|
||||||
|
|
||||||
|
var perSite = await repo.ComputePerSiteKpisAsync(stuckCutoff, intervalSince);
|
||||||
|
|
||||||
|
var a = Assert.Single(perSite, s => s.SourceSite == siteA);
|
||||||
|
Assert.Equal(2, a.BufferedCount);
|
||||||
|
Assert.Equal(1, a.ParkedCount);
|
||||||
|
Assert.Equal(1, a.StuckCount);
|
||||||
|
Assert.NotNull(a.OldestPendingAge);
|
||||||
|
|
||||||
|
var b = Assert.Single(perSite, s => s.SourceSite == siteB);
|
||||||
|
Assert.Equal(0, b.BufferedCount);
|
||||||
|
Assert.Equal(1, b.DeliveredLastInterval);
|
||||||
|
// siteB has no non-terminal rows — no oldest-pending age.
|
||||||
|
Assert.Null(b.OldestPendingAge);
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ------------------------------------------------------------
|
// --- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
private ScadaLinkDbContext CreateContext()
|
private ScadaLinkDbContext CreateContext()
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster.Tools.Client;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.AuditLog.Site;
|
||||||
|
using ScadaLink.AuditLog.Site.Telemetry;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Communication;
|
||||||
|
using ScadaLink.Communication.Actors;
|
||||||
|
|
||||||
|
namespace ScadaLink.IntegrationTests.AuditLog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end integration test for the Audit Log (#23) site→central push path
|
||||||
|
/// introduced by the "real ClusterClient-based site audit push client" follow-up.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Exercises the full production chain in one actor system: the real
|
||||||
|
/// <see cref="SqliteAuditWriter"/> site SQLite hot-path, the real
|
||||||
|
/// <see cref="SiteAuditTelemetryActor"/> drain loop, the real
|
||||||
|
/// <see cref="ClusterClientSiteAuditClient"/>, the real
|
||||||
|
/// <see cref="SiteCommunicationActor"/> forward, the real
|
||||||
|
/// <see cref="CentralCommunicationActor"/> routing, and the real
|
||||||
|
/// <c>AuditLogIngestActor</c> ingest — only the cross-cluster ClusterClient
|
||||||
|
/// transport itself is substituted by an in-process <see cref="ClusterClientRelay"/>
|
||||||
|
/// that unwraps <see cref="ClusterClient.Send"/> exactly as a real ClusterClient
|
||||||
|
/// would (a multi-node cluster is out of scope for an in-process test).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The central audit store is an in-memory <see cref="IAuditLogRepository"/> —
|
||||||
|
/// the production <c>AuditLogRepository</c> emits SQL Server-specific T-SQL and
|
||||||
|
/// needs an MSSQL container, which this test deliberately avoids. The test
|
||||||
|
/// asserts both ends of the contract: a central <c>AuditLog</c> row appears AND
|
||||||
|
/// the site SQLite row flips from <see cref="AuditForwardState.Pending"/> to
|
||||||
|
/// <see cref="AuditForwardState.Forwarded"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public class SiteAuditPushFlowTests : TestKit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// In-process stand-in for a real Akka ClusterClient: unwraps a
|
||||||
|
/// <see cref="ClusterClient.Send"/> and forwards the inner message to the
|
||||||
|
/// central actor, preserving the original sender so the reply routes back to
|
||||||
|
/// the site's Ask. A real ClusterClient does exactly this across the cluster
|
||||||
|
/// boundary; the in-process relay keeps the test free of a multi-node setup.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ClusterClientRelay : ReceiveActor
|
||||||
|
{
|
||||||
|
public ClusterClientRelay(IActorRef central)
|
||||||
|
{
|
||||||
|
Receive<ClusterClient.Send>(send => central.Forward(send.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe in-memory <see cref="IAuditLogRepository"/>. Only
|
||||||
|
/// <see cref="InsertIfNotExistsAsync"/> is exercised by the ingest path; the
|
||||||
|
/// rest throw because they are not reachable from this test.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class InMemoryAuditLogRepository : IAuditLogRepository
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, AuditEvent> _rows = new();
|
||||||
|
|
||||||
|
public IReadOnlyCollection<AuditEvent> Rows => _rows.Values.ToList();
|
||||||
|
|
||||||
|
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
|
// First-write-wins idempotency, mirroring the production repository.
|
||||||
|
_rows.TryAdd(evt.EventId, evt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
|
DateTime threshold, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||||
|
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditEvent NewPendingEvent(Guid id) => new()
|
||||||
|
{
|
||||||
|
EventId = id,
|
||||||
|
OccurredAtUtc = new DateTime(2026, 5, 21, 9, 0, 0, DateTimeKind.Utc),
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = AuditKind.ApiCall,
|
||||||
|
Status = AuditStatus.Delivered,
|
||||||
|
SourceSiteId = "site-1",
|
||||||
|
Target = "ext-system-1",
|
||||||
|
PayloadTruncated = false,
|
||||||
|
ForwardState = AuditForwardState.Pending,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SiteAuditEvent_DrainsToCentral_AndFlipsSiteRowToForwarded()
|
||||||
|
{
|
||||||
|
// ── Central side ──────────────────────────────────────────────────
|
||||||
|
// Real AuditLogIngestActor over an in-memory repository (test-mode ctor).
|
||||||
|
var centralRepo = new InMemoryAuditLogRepository();
|
||||||
|
var ingestActor = Sys.ActorOf(Props.Create(() =>
|
||||||
|
new ScadaLink.AuditLog.Central.AuditLogIngestActor(
|
||||||
|
centralRepo,
|
||||||
|
NullLogger<ScadaLink.AuditLog.Central.AuditLogIngestActor>.Instance)));
|
||||||
|
|
||||||
|
// Real CentralCommunicationActor. Its periodic site-address refresh
|
||||||
|
// resolves an ISiteRepository from this provider; an empty result keeps
|
||||||
|
// the refresh a clean no-op and never touches the audit-ingest path.
|
||||||
|
var siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
siteRepo.GetAllSitesAsync().Returns(Array.Empty<Site>());
|
||||||
|
var centralServices = new ServiceCollection();
|
||||||
|
centralServices.AddScoped(_ => siteRepo);
|
||||||
|
var centralProvider = centralServices.BuildServiceProvider();
|
||||||
|
|
||||||
|
var centralCommActor = Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(
|
||||||
|
centralProvider,
|
||||||
|
new DefaultSiteClientFactory(),
|
||||||
|
TimeSpan.FromSeconds(5))));
|
||||||
|
centralCommActor.Tell(new RegisterAuditIngest(ingestActor));
|
||||||
|
|
||||||
|
// ── Site side ─────────────────────────────────────────────────────
|
||||||
|
// Real SqliteAuditWriter on a file-backed SQLite db (the site hot-path
|
||||||
|
// + Pending queue). A temp file so it survives across DI scopes.
|
||||||
|
var dbPath = Path.Combine(Path.GetTempPath(), $"auditpush-{Guid.NewGuid():N}.db");
|
||||||
|
var writerOptions = Options.Create(new SqliteAuditWriterOptions { DatabasePath = dbPath });
|
||||||
|
await using var writer = new SqliteAuditWriter(
|
||||||
|
writerOptions, NullLogger<SqliteAuditWriter>.Instance);
|
||||||
|
|
||||||
|
// Real SiteCommunicationActor. RegisterCentralClient is given the relay
|
||||||
|
// standing in for the central ClusterClient.
|
||||||
|
var siteCommActor = Sys.ActorOf(Props.Create(() => new SiteCommunicationActor(
|
||||||
|
"site-1",
|
||||||
|
new CommunicationOptions(),
|
||||||
|
CreateTestProbe().Ref))); // deployment-manager proxy is unused here
|
||||||
|
var relay = Sys.ActorOf(Props.Create(() => new ClusterClientRelay(centralCommActor)));
|
||||||
|
siteCommActor.Tell(new RegisterCentralClient(relay));
|
||||||
|
|
||||||
|
// The production site audit push client — the unit under integration.
|
||||||
|
var auditClient = new ClusterClientSiteAuditClient(
|
||||||
|
siteCommActor, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// Real SiteAuditTelemetryActor drains the writer's Pending queue and
|
||||||
|
// pushes via the client. Fast intervals so the test completes quickly.
|
||||||
|
var telemetryOptions = Options.Create(new SiteAuditTelemetryOptions
|
||||||
|
{
|
||||||
|
BatchSize = 256,
|
||||||
|
BusyIntervalSeconds = 1,
|
||||||
|
IdleIntervalSeconds = 1,
|
||||||
|
});
|
||||||
|
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
|
||||||
|
writer,
|
||||||
|
auditClient,
|
||||||
|
telemetryOptions,
|
||||||
|
NullLogger<SiteAuditTelemetryActor>.Instance)));
|
||||||
|
|
||||||
|
// ── Act ───────────────────────────────────────────────────────────
|
||||||
|
// Write an audit event onto the site SQLite hot-path. It lands Pending.
|
||||||
|
var eventId = Guid.NewGuid();
|
||||||
|
await writer.WriteAsync(NewPendingEvent(eventId));
|
||||||
|
|
||||||
|
// ── Assert ────────────────────────────────────────────────────────
|
||||||
|
// Within ~10s the drain loop pushes the event to central AND flips the
|
||||||
|
// site row to Forwarded.
|
||||||
|
await AwaitAssertAsync(async () =>
|
||||||
|
{
|
||||||
|
// Central received and persisted the row.
|
||||||
|
Assert.Contains(centralRepo.Rows, r => r.EventId == eventId);
|
||||||
|
|
||||||
|
// The site row reached AuditForwardState.Forwarded specifically —
|
||||||
|
// not merely "no longer Pending" (a Reconciled row would also leave
|
||||||
|
// ReadPendingAsync, so we assert the positive Forwarded state).
|
||||||
|
var forwarded = await writer.ReadForwardedAsync(256, CancellationToken.None);
|
||||||
|
var row = Assert.Single(forwarded, r => r.EventId == eventId);
|
||||||
|
Assert.Equal(AuditForwardState.Forwarded, row.ForwardState);
|
||||||
|
}, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(250));
|
||||||
|
|
||||||
|
// The central-persisted row carries the central-stamped IngestedAtUtc.
|
||||||
|
var ingested = centralRepo.Rows.Single(r => r.EventId == eventId);
|
||||||
|
Assert.NotNull(ingested.IngestedAtUtc);
|
||||||
|
|
||||||
|
// Cleanup the temp SQLite file.
|
||||||
|
try { File.Delete(dbPath); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -367,6 +367,89 @@ public class AuditEndpointsTests
|
|||||||
Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
|
Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseFilter_RepeatedParams_ParseIntoMultiValueLists()
|
||||||
|
{
|
||||||
|
// Repeated query params (channel=A&channel=B …) must widen to multi-value
|
||||||
|
// filter lists — one element per supplied value.
|
||||||
|
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||||
|
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||||
|
{
|
||||||
|
["channel"] = new[] { "ApiOutbound", "DbOutbound" },
|
||||||
|
["kind"] = new[] { "ApiCall", "DbWrite" },
|
||||||
|
["status"] = new[] { "Failed", "Parked" },
|
||||||
|
["sourceSiteId"] = new[] { "plant-a", "plant-b" },
|
||||||
|
});
|
||||||
|
|
||||||
|
var filter = AuditEndpoints.ParseFilter(query);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, filter.Channels);
|
||||||
|
Assert.Equal(new[] { AuditKind.ApiCall, AuditKind.DbWrite }, filter.Kinds);
|
||||||
|
Assert.Equal(new[] { AuditStatus.Failed, AuditStatus.Parked }, filter.Statuses);
|
||||||
|
Assert.Equal(new[] { "plant-a", "plant-b" }, filter.SourceSiteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseFilter_SingleParam_ParsesIntoOneElementList()
|
||||||
|
{
|
||||||
|
// The single-valued contract still holds — one param yields a
|
||||||
|
// one-element list, not a scalar.
|
||||||
|
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||||
|
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||||
|
{
|
||||||
|
["channel"] = "ApiOutbound",
|
||||||
|
["status"] = "Delivered",
|
||||||
|
});
|
||||||
|
|
||||||
|
var filter = AuditEndpoints.ParseFilter(query);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels);
|
||||||
|
Assert.Equal(new[] { AuditStatus.Delivered }, filter.Statuses);
|
||||||
|
Assert.Null(filter.Kinds);
|
||||||
|
Assert.Null(filter.SourceSiteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseFilter_UnparseableValuesInRepeatedSet_AreDroppedSilently()
|
||||||
|
{
|
||||||
|
// Lax-parse contract: an unrecognised enum name is dropped, the rest of
|
||||||
|
// the repeated set survives — no 400, no whole-set drop.
|
||||||
|
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||||
|
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||||
|
{
|
||||||
|
["channel"] = new[] { "ApiOutbound", "Bogus", "Notification" },
|
||||||
|
["status"] = new[] { "Nonsense" },
|
||||||
|
});
|
||||||
|
|
||||||
|
var filter = AuditEndpoints.ParseFilter(query);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, filter.Channels);
|
||||||
|
// Every value unparseable → the dimension stays unconstrained (null).
|
||||||
|
Assert.Null(filter.Statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
|
||||||
|
{
|
||||||
|
// End-to-end: a repeated channel= query param must surface at the
|
||||||
|
// repository as a two-element Channels list.
|
||||||
|
var (client, repo, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||||
|
using (host)
|
||||||
|
{
|
||||||
|
var response = await client.SendAsync(Get(
|
||||||
|
"/api/audit/query?channel=ApiOutbound&channel=DbOutbound"));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
await repo.Received().QueryAsync(
|
||||||
|
Arg.Is<AuditLogQueryFilter>(f =>
|
||||||
|
f.Channels != null && f.Channels.Count == 2 &&
|
||||||
|
f.Channels.Contains(AuditChannel.ApiOutbound) &&
|
||||||
|
f.Channels.Contains(AuditChannel.DbOutbound)),
|
||||||
|
Arg.Any<AuditLogPaging>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParsePaging_HalfSuppliedCursor_IsDropped()
|
public void ParsePaging_HalfSuppliedCursor_IsDropped()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,10 +70,12 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private IActorRef CreateActor(ISiteCallAuditRepository repository) =>
|
private IActorRef CreateActor(
|
||||||
|
ISiteCallAuditRepository repository, SiteCallAuditOptions? options = null) =>
|
||||||
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
||||||
repository,
|
repository,
|
||||||
NullLogger<SiteCallAuditActor>.Instance)));
|
NullLogger<SiteCallAuditActor>.Instance,
|
||||||
|
options)));
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted()
|
public async Task Receive_UpsertSiteCallCommand_Persists_Replies_Accepted()
|
||||||
@@ -182,6 +184,350 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
Assert.Equal(healthyId, rows[0].TrackedOperationId);
|
Assert.Equal(healthyId, rows[0].TrackedOperationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Task 4: read-side (query / detail / KPI) handlers ──
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallQueryRequest_FilterBySourceSite_ReturnsMatchingSummaries()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(repo);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: t0));
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||||
|
createdAtUtc: t0.AddMinutes(1), terminal: true));
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new SiteCallQueryRequest(
|
||||||
|
"corr-q1", StatusFilter: null, SourceSiteFilter: siteId, ChannelFilter: null,
|
||||||
|
TargetKeyword: null, StuckOnly: false, FromUtc: null, ToUtc: null,
|
||||||
|
AfterCreatedAtUtc: null, AfterId: null, PageSize: 50),
|
||||||
|
TestActor);
|
||||||
|
|
||||||
|
var response = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(response.Success);
|
||||||
|
Assert.Equal("corr-q1", response.CorrelationId);
|
||||||
|
Assert.Equal(2, response.SiteCalls.Count);
|
||||||
|
Assert.All(response.SiteCalls, s => Assert.Equal(siteId, s.SourceSite));
|
||||||
|
// Newest first — ordered (CreatedAtUtc DESC).
|
||||||
|
Assert.Equal("Delivered", response.SiteCalls[0].Status);
|
||||||
|
// Cursor echoes the last (oldest) row of the page.
|
||||||
|
Assert.Equal(t0, response.NextAfterCreatedAtUtc);
|
||||||
|
Assert.Equal(response.SiteCalls[^1].TrackedOperationId, response.NextAfterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(repo);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc);
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, createdAtUtc: t0.AddMinutes(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new SiteCallQueryRequest(
|
||||||
|
"corr-q2", null, siteId, null, null, false, null, null, null, null, PageSize: 2),
|
||||||
|
TestActor);
|
||||||
|
var page1 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.Equal(2, page1.SiteCalls.Count);
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new SiteCallQueryRequest(
|
||||||
|
"corr-q3", null, siteId, null, null, false, null, null,
|
||||||
|
page1.NextAfterCreatedAtUtc, page1.NextAfterId, PageSize: 2),
|
||||||
|
TestActor);
|
||||||
|
var page2 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.Single(page2.SiteCalls);
|
||||||
|
|
||||||
|
// No overlap across the two pages.
|
||||||
|
var allIds = page1.SiteCalls.Concat(page2.SiteCalls)
|
||||||
|
.Select(s => s.TrackedOperationId).ToHashSet();
|
||||||
|
Assert.Equal(3, allIds.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallQueryRequest_StuckOnly_ReturnsOnlyOldNonTerminalRows()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
// 10-minute stuck threshold (the production default).
|
||||||
|
var actor = CreateActor(repo, new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.FromMinutes(10) });
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
// Stuck: non-terminal (Attempted, TerminalAtUtc null), created 30 min ago.
|
||||||
|
var stuckId = TrackedOperationId.New();
|
||||||
|
await repo.UpsertAsync(NewRow(stuckId, siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||||
|
// Not stuck: non-terminal but recent.
|
||||||
|
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||||
|
// Not stuck: old but terminal (Delivered, TerminalAtUtc set).
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||||
|
createdAtUtc: now.AddMinutes(-40), terminal: true));
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new SiteCallQueryRequest(
|
||||||
|
"corr-stuck", null, siteId, null, null, StuckOnly: true,
|
||||||
|
null, null, null, null, PageSize: 50),
|
||||||
|
TestActor);
|
||||||
|
|
||||||
|
var response = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(response.Success);
|
||||||
|
Assert.Single(response.SiteCalls);
|
||||||
|
Assert.Equal(stuckId.Value, response.SiteCalls[0].TrackedOperationId);
|
||||||
|
Assert.True(response.SiteCalls[0].IsStuck);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallQueryRequest_StuckOnly_PagesAreFull_NoEmptyPagesWithCursor()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(repo, new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.FromMinutes(10) });
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
// Three stuck rows interleaved (by CreatedAtUtc) with three non-stuck
|
||||||
|
// rows: recent non-terminal and old-but-terminal. With the StuckOnly
|
||||||
|
// filter pushed into SQL, a page-size-2 query must return exactly the
|
||||||
|
// stuck rows two-per-page — never an under-filled page with a non-null
|
||||||
|
// next cursor caused by post-filtering.
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Attempted",
|
||||||
|
createdAtUtc: now.AddMinutes(-30 - i)));
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Attempted",
|
||||||
|
createdAtUtc: now.AddMinutes(-2 - i)));
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||||
|
createdAtUtc: now.AddMinutes(-40 - i), terminal: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new SiteCallQueryRequest(
|
||||||
|
"corr-stuck-p1", null, siteId, null, null, StuckOnly: true,
|
||||||
|
null, null, null, null, PageSize: 2),
|
||||||
|
TestActor);
|
||||||
|
var page1 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(page1.Success);
|
||||||
|
// Page is full — two stuck rows, both honestly stuck.
|
||||||
|
Assert.Equal(2, page1.SiteCalls.Count);
|
||||||
|
Assert.All(page1.SiteCalls, s => Assert.True(s.IsStuck));
|
||||||
|
Assert.NotNull(page1.NextAfterCreatedAtUtc);
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new SiteCallQueryRequest(
|
||||||
|
"corr-stuck-p2", null, siteId, null, null, StuckOnly: true,
|
||||||
|
null, null, page1.NextAfterCreatedAtUtc, page1.NextAfterId,
|
||||||
|
PageSize: 2),
|
||||||
|
TestActor);
|
||||||
|
var page2 = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(page2.Success);
|
||||||
|
// Final page — the third stuck row, the only remaining match.
|
||||||
|
Assert.Single(page2.SiteCalls);
|
||||||
|
Assert.All(page2.SiteCalls, s => Assert.True(s.IsStuck));
|
||||||
|
|
||||||
|
// No overlap, exactly the three stuck rows across both pages.
|
||||||
|
var allIds = page1.SiteCalls.Concat(page2.SiteCalls)
|
||||||
|
.Select(s => s.TrackedOperationId).ToHashSet();
|
||||||
|
Assert.Equal(3, allIds.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallDetailRequest_KnownId_ReturnsFullDetail()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
var id = TrackedOperationId.New();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(repo);
|
||||||
|
|
||||||
|
await repo.UpsertAsync(NewRow(id, siteId, status: "Attempted", retryCount: 2, lastError: "503"));
|
||||||
|
|
||||||
|
actor.Tell(new SiteCallDetailRequest("corr-d1", id.Value), TestActor);
|
||||||
|
|
||||||
|
var response = ExpectMsg<SiteCallDetailResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(response.Success);
|
||||||
|
Assert.NotNull(response.Detail);
|
||||||
|
Assert.Equal(id.Value, response.Detail!.TrackedOperationId);
|
||||||
|
Assert.Equal("Attempted", response.Detail.Status);
|
||||||
|
Assert.Equal(2, response.Detail.RetryCount);
|
||||||
|
Assert.Equal("503", response.Detail.LastError);
|
||||||
|
Assert.Equal(siteId, response.Detail.SourceSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallDetailRequest_UnknownId_RepliesNotFound()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(repo);
|
||||||
|
|
||||||
|
actor.Tell(new SiteCallDetailRequest("corr-d2", Guid.NewGuid()), TestActor);
|
||||||
|
|
||||||
|
var response = ExpectMsg<SiteCallDetailResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.False(response.Success);
|
||||||
|
Assert.Null(response.Detail);
|
||||||
|
Assert.NotNull(response.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallKpiRequest_ComputesPointInTimeCounts()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(repo, new SiteCallAuditOptions
|
||||||
|
{
|
||||||
|
StuckAgeThreshold = TimeSpan.FromMinutes(10),
|
||||||
|
KpiInterval = TimeSpan.FromHours(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
// Buffered (non-terminal Attempted) + stuck (created 30 min ago).
|
||||||
|
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||||
|
// Buffered (non-terminal Attempted), not stuck.
|
||||||
|
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||||
|
// Parked (terminal).
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Parked",
|
||||||
|
createdAtUtc: now.AddMinutes(-5), terminal: true));
|
||||||
|
// Delivered within the interval.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||||
|
createdAtUtc: now.AddMinutes(-3), updatedAtUtc: now.AddMinutes(-1), terminal: true));
|
||||||
|
|
||||||
|
actor.Tell(new SiteCallKpiRequest("corr-kpi"), TestActor);
|
||||||
|
|
||||||
|
var response = ExpectMsg<SiteCallKpiResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(response.Success);
|
||||||
|
// Per-site rows are isolated by the unique siteId — but KPIs are global,
|
||||||
|
// so assert the floor (>=) rather than exact counts: other tests' rows
|
||||||
|
// may share the table.
|
||||||
|
Assert.True(response.BufferedCount >= 2);
|
||||||
|
Assert.True(response.ParkedCount >= 1);
|
||||||
|
Assert.True(response.DeliveredLastInterval >= 1);
|
||||||
|
Assert.True(response.StuckCount >= 1);
|
||||||
|
Assert.NotNull(response.OldestPendingAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task PerSiteSiteCallKpiRequest_ScopesCountsToEachSite()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(repo, new SiteCallAuditOptions
|
||||||
|
{
|
||||||
|
StuckAgeThreshold = TimeSpan.FromMinutes(10),
|
||||||
|
KpiInterval = TimeSpan.FromHours(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
// Non-terminal Attempted, created 30 min ago — buffered + stuck.
|
||||||
|
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||||
|
// Terminal Parked.
|
||||||
|
await repo.UpsertAsync(NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Parked",
|
||||||
|
createdAtUtc: now.AddMinutes(-5), terminal: true));
|
||||||
|
|
||||||
|
actor.Tell(new PerSiteSiteCallKpiRequest("corr-psk"), TestActor);
|
||||||
|
|
||||||
|
var response = ExpectMsg<PerSiteSiteCallKpiResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(response.Success);
|
||||||
|
|
||||||
|
var mySite = Assert.Single(response.Sites, s => s.SourceSite == siteId);
|
||||||
|
Assert.Equal(1, mySite.BufferedCount);
|
||||||
|
Assert.Equal(1, mySite.ParkedCount);
|
||||||
|
Assert.Equal(1, mySite.StuckCount);
|
||||||
|
Assert.NotNull(mySite.OldestPendingAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallQueryRequest_RepoThrows_RepliesFailure_ActorStaysAlive()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var realRepo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(new QueryThrowingRepository(realRepo));
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new SiteCallQueryRequest(
|
||||||
|
"corr-fault", null, siteId, null, null, false, null, null, null, null, 50),
|
||||||
|
TestActor);
|
||||||
|
|
||||||
|
var response = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.False(response.Success);
|
||||||
|
Assert.Empty(response.SiteCalls);
|
||||||
|
Assert.NotNull(response.ErrorMessage);
|
||||||
|
Assert.Equal("corr-fault", response.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test double whose <see cref="ISiteCallAuditRepository.QueryAsync"/> always
|
||||||
|
/// throws — used to verify the query handler's failure projection produces a
|
||||||
|
/// <c>Success=false</c> response without crashing the actor.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class QueryThrowingRepository : ISiteCallAuditRepository
|
||||||
|
{
|
||||||
|
private readonly ISiteCallAuditRepository _inner;
|
||||||
|
|
||||||
|
public QueryThrowingRepository(ISiteCallAuditRepository inner)
|
||||||
|
{
|
||||||
|
_inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
||||||
|
_inner.UpsertAsync(siteCall, ct);
|
||||||
|
|
||||||
|
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||||
|
_inner.GetAsync(id, ct);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||||
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||||
|
throw new InvalidOperationException("simulated query failure");
|
||||||
|
|
||||||
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||||
|
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||||
|
|
||||||
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tiny test double that delegates to a real repository but throws on a
|
/// Tiny test double that delegates to a real repository but throws on a
|
||||||
/// specified <see cref="TrackedOperationId"/>. Used to verify the actor's
|
/// specified <see cref="TrackedOperationId"/>. Used to verify the actor's
|
||||||
@@ -217,5 +563,13 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
|
|
||||||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||||
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
_inner.PurgeTerminalAsync(olderThanUtc, ct);
|
||||||
|
|
||||||
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
_inner.ComputeKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
_inner.ComputePerSiteKpisAsync(stuckCutoff, intervalSince, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ScadaLink.SiteCallAudit.Tests;
|
||||||
|
|
||||||
|
public class SiteCallAuditOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_AreExpectedValues()
|
||||||
|
{
|
||||||
|
var options = new SiteCallAuditOptions();
|
||||||
|
|
||||||
|
// Stuck threshold mirrors NotificationOutboxOptions.StuckAgeThreshold.
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(10), options.StuckAgeThreshold);
|
||||||
|
// KPI interval mirrors NotificationOutboxOptions.DeliveredKpiWindow.
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(1), options.KpiInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
212
tests/ScadaLink.SiteCallAudit.Tests/SiteCallRelayTests.cs
Normal file
212
tests/ScadaLink.SiteCallAudit.Tests/SiteCallRelayTests.cs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
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.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 5 (#22 Retry/Discard relay): tests for <see cref="SiteCallAuditActor"/>
|
||||||
|
/// relaying operator Retry/Discard on a parked Site Call down to the owning
|
||||||
|
/// site. The relay routes a <see cref="RetryParkedOperation"/> /
|
||||||
|
/// <see cref="DiscardParkedOperation"/> command via a <see cref="SiteEnvelope"/>
|
||||||
|
/// to the <see cref="ScadaLink.Communication.Actors.CentralCommunicationActor"/>
|
||||||
|
/// (stood in by a <c>TestProbe</c> here) and awaits the site's
|
||||||
|
/// <see cref="ParkedOperationActionAck"/>. These tests never touch the
|
||||||
|
/// <c>SiteCalls</c> repository — central never mutates the mirror row.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteCallRelayTests : TestKit
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A repository that fails every call — the relay path must NEVER touch the
|
||||||
|
/// <c>SiteCalls</c> table (central is not the source of truth), so any
|
||||||
|
/// invocation here is a test failure surfaced as an exception.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ThrowingRepository : ISiteCallAuditRepository
|
||||||
|
{
|
||||||
|
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
||||||
|
throw new InvalidOperationException("relay must not write the SiteCalls row");
|
||||||
|
|
||||||
|
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||||||
|
throw new InvalidOperationException("relay must not read the SiteCalls row");
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||||||
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||||||
|
throw new InvalidOperationException("relay must not query the SiteCalls table");
|
||||||
|
|
||||||
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||||||
|
throw new InvalidOperationException("relay must not purge");
|
||||||
|
|
||||||
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
throw new InvalidOperationException("relay must not compute KPIs");
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||||||
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
||||||
|
throw new InvalidOperationException("relay must not compute per-site KPIs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="SiteCallAuditActor"/> with a throwing repository and a
|
||||||
|
/// short relay timeout, and registers <paramref name="centralComm"/> as the
|
||||||
|
/// central→site transport.
|
||||||
|
/// </summary>
|
||||||
|
private IActorRef CreateActor(IActorRef centralComm)
|
||||||
|
{
|
||||||
|
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
||||||
|
new ThrowingRepository(),
|
||||||
|
NullLogger<SiteCallAuditActor>.Instance,
|
||||||
|
options)));
|
||||||
|
actor.Tell(new RegisterCentralCommunication(centralComm));
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetrySiteCall_RoutesRetryParkedOperation_ToOwningSite()
|
||||||
|
{
|
||||||
|
var central = CreateTestProbe();
|
||||||
|
var actor = CreateActor(central.Ref);
|
||||||
|
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
actor.Tell(new RetrySiteCallRequest("corr-1", id, "site-north"));
|
||||||
|
|
||||||
|
// The relay must wrap a RetryParkedOperation in a SiteEnvelope addressed
|
||||||
|
// to the owning site.
|
||||||
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
||||||
|
Assert.Equal("site-north", envelope.SiteId);
|
||||||
|
var relay = Assert.IsType<RetryParkedOperation>(envelope.Message);
|
||||||
|
Assert.Equal(id, relay.TrackedOperationId.Value);
|
||||||
|
|
||||||
|
// The site applies it and acks; the relay reports Applied.
|
||||||
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true));
|
||||||
|
|
||||||
|
var response = ExpectMsg<RetrySiteCallResponse>();
|
||||||
|
Assert.Equal("corr-1", response.CorrelationId);
|
||||||
|
Assert.Equal(SiteCallRelayOutcome.Applied, response.Outcome);
|
||||||
|
Assert.True(response.Success);
|
||||||
|
Assert.True(response.SiteReachable);
|
||||||
|
Assert.Null(response.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscardSiteCall_RoutesDiscardParkedOperation_ToOwningSite()
|
||||||
|
{
|
||||||
|
var central = CreateTestProbe();
|
||||||
|
var actor = CreateActor(central.Ref);
|
||||||
|
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
actor.Tell(new DiscardSiteCallRequest("corr-2", id, "site-south"));
|
||||||
|
|
||||||
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
||||||
|
Assert.Equal("site-south", envelope.SiteId);
|
||||||
|
var relay = Assert.IsType<DiscardParkedOperation>(envelope.Message);
|
||||||
|
Assert.Equal(id, relay.TrackedOperationId.Value);
|
||||||
|
|
||||||
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true));
|
||||||
|
|
||||||
|
var response = ExpectMsg<DiscardSiteCallResponse>();
|
||||||
|
Assert.Equal(SiteCallRelayOutcome.Applied, response.Outcome);
|
||||||
|
Assert.True(response.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetrySiteCall_SiteRepliesNotApplied_ReportsNotParked()
|
||||||
|
{
|
||||||
|
var central = CreateTestProbe();
|
||||||
|
var actor = CreateActor(central.Ref);
|
||||||
|
|
||||||
|
actor.Tell(new RetrySiteCallRequest("corr-3", Guid.NewGuid(), "site-north"));
|
||||||
|
|
||||||
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
||||||
|
var relay = (RetryParkedOperation)envelope.Message;
|
||||||
|
// The site found nothing parked — a definitive answer, not a failure.
|
||||||
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: false));
|
||||||
|
|
||||||
|
var response = ExpectMsg<RetrySiteCallResponse>();
|
||||||
|
Assert.Equal(SiteCallRelayOutcome.NotParked, response.Outcome);
|
||||||
|
Assert.False(response.Success);
|
||||||
|
Assert.True(response.SiteReachable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetrySiteCall_SiteRepliesError_ReportsOperationFailed()
|
||||||
|
{
|
||||||
|
var central = CreateTestProbe();
|
||||||
|
var actor = CreateActor(central.Ref);
|
||||||
|
|
||||||
|
actor.Tell(new RetrySiteCallRequest("corr-4", Guid.NewGuid(), "site-north"));
|
||||||
|
|
||||||
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
||||||
|
var relay = (RetryParkedOperation)envelope.Message;
|
||||||
|
central.Reply(new ParkedOperationActionAck(
|
||||||
|
relay.CorrelationId, Applied: false, "Parked message handler not available"));
|
||||||
|
|
||||||
|
var response = ExpectMsg<RetrySiteCallResponse>();
|
||||||
|
Assert.Equal(SiteCallRelayOutcome.OperationFailed, response.Outcome);
|
||||||
|
Assert.False(response.Success);
|
||||||
|
// The site WAS reached — this is an operation failure, not unreachable.
|
||||||
|
Assert.True(response.SiteReachable);
|
||||||
|
Assert.NotNull(response.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetrySiteCall_SiteNeverReplies_ReportsSiteUnreachable()
|
||||||
|
{
|
||||||
|
// A central comm probe that silently drops the relay — models an offline
|
||||||
|
// site / no ClusterClient route: the Ask times out.
|
||||||
|
var central = CreateTestProbe();
|
||||||
|
var actor = CreateActor(central.Ref);
|
||||||
|
|
||||||
|
actor.Tell(new RetrySiteCallRequest("corr-5", Guid.NewGuid(), "site-offline"));
|
||||||
|
|
||||||
|
central.ExpectMsg<SiteEnvelope>();
|
||||||
|
// Probe does not reply — the relay Ask times out (RelayTimeout = 500ms).
|
||||||
|
|
||||||
|
var response = ExpectMsg<RetrySiteCallResponse>(TimeSpan.FromSeconds(3));
|
||||||
|
Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome);
|
||||||
|
Assert.False(response.Success);
|
||||||
|
// The distinct unreachable signal the UI relies on.
|
||||||
|
Assert.False(response.SiteReachable);
|
||||||
|
Assert.NotNull(response.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscardSiteCall_SiteNeverReplies_ReportsSiteUnreachable()
|
||||||
|
{
|
||||||
|
var central = CreateTestProbe();
|
||||||
|
var actor = CreateActor(central.Ref);
|
||||||
|
|
||||||
|
actor.Tell(new DiscardSiteCallRequest("corr-6", Guid.NewGuid(), "site-offline"));
|
||||||
|
|
||||||
|
central.ExpectMsg<SiteEnvelope>();
|
||||||
|
|
||||||
|
var response = ExpectMsg<DiscardSiteCallResponse>(TimeSpan.FromSeconds(3));
|
||||||
|
Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome);
|
||||||
|
Assert.False(response.SiteReachable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetrySiteCall_BeforeCentralCommunicationRegistered_ReportsSiteUnreachable()
|
||||||
|
{
|
||||||
|
// No RegisterCentralCommunication — the actor has no transport to reach
|
||||||
|
// any site, so the only honest answer is "unreachable".
|
||||||
|
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
||||||
|
new ThrowingRepository(),
|
||||||
|
NullLogger<SiteCallAuditActor>.Instance,
|
||||||
|
options)));
|
||||||
|
|
||||||
|
actor.Tell(new RetrySiteCallRequest("corr-7", Guid.NewGuid(), "site-north"));
|
||||||
|
|
||||||
|
var response = ExpectMsg<RetrySiteCallResponse>();
|
||||||
|
Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome);
|
||||||
|
Assert.False(response.SiteReachable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Messages.RemoteQuery;
|
||||||
|
using ScadaLink.Commons.Types;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.StoreAndForward.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 5 (#22 Retry/Discard relay): tests the site-side execution of a
|
||||||
|
/// central→site <see cref="RetryParkedOperation"/> / <see cref="DiscardParkedOperation"/>
|
||||||
|
/// relay command on the <see cref="ParkedMessageHandlerActor"/>. The cached
|
||||||
|
/// call's S&F buffer message id is the <see cref="TrackedOperationId"/>, so
|
||||||
|
/// the handler resolves the parked row directly from the tracked id and reuses
|
||||||
|
/// the existing parked-message Retry/Discard primitive. A non-parked operation
|
||||||
|
/// must be a safe no-op (<c>Applied=false</c>), never a corruption.
|
||||||
|
/// </summary>
|
||||||
|
public class ParkedOperationRelayTests : TestKit, IAsyncLifetime, IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _keepAlive;
|
||||||
|
private readonly StoreAndForwardStorage _storage;
|
||||||
|
private readonly StoreAndForwardService _service;
|
||||||
|
|
||||||
|
public ParkedOperationRelayTests()
|
||||||
|
{
|
||||||
|
var connStr = $"Data Source=RelayTests_{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||||
|
_keepAlive = new SqliteConnection(connStr);
|
||||||
|
_keepAlive.Open();
|
||||||
|
|
||||||
|
_storage = new StoreAndForwardStorage(connStr, NullLogger<StoreAndForwardStorage>.Instance);
|
||||||
|
|
||||||
|
var options = new StoreAndForwardOptions
|
||||||
|
{
|
||||||
|
DefaultRetryInterval = TimeSpan.Zero,
|
||||||
|
DefaultMaxRetries = 1,
|
||||||
|
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
||||||
|
ReplicationEnabled = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
_service = new StoreAndForwardService(
|
||||||
|
_storage, options, NullLogger<StoreAndForwardService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync() => await _storage.InitializeAsync();
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing) _keepAlive.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues a cached-call message whose S&F id is the supplied
|
||||||
|
/// <see cref="TrackedOperationId"/> and parks it via the retry sweep.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ParkCachedCallAsync(TrackedOperationId id)
|
||||||
|
{
|
||||||
|
_service.RegisterDeliveryHandler(
|
||||||
|
StoreAndForwardCategory.ExternalSystem, _ => throw new HttpRequestException("always fails"));
|
||||||
|
await _service.EnqueueAsync(
|
||||||
|
StoreAndForwardCategory.ExternalSystem, "ERP.GetOrder", """{}""",
|
||||||
|
maxRetries: 1, messageId: id.ToString());
|
||||||
|
await _service.RetryPendingMessagesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RetryParkedOperation_ParkedCachedCall_ResetsToPendingAndApplied()
|
||||||
|
{
|
||||||
|
var id = TrackedOperationId.New();
|
||||||
|
await ParkCachedCallAsync(id);
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||||
|
actor.Tell(new RetryParkedOperation("corr-1", id));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||||
|
Assert.True(ack.Applied);
|
||||||
|
Assert.Equal("corr-1", ack.CorrelationId);
|
||||||
|
Assert.Null(ack.ErrorMessage);
|
||||||
|
|
||||||
|
// The parked row was reset back to Pending so the retry sweep picks it up.
|
||||||
|
var msg = await _storage.GetMessageByIdAsync(id.ToString());
|
||||||
|
Assert.NotNull(msg);
|
||||||
|
Assert.Equal(StoreAndForwardMessageStatus.Pending, msg!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardParkedOperation_ParkedCachedCall_RemovesRowAndApplied()
|
||||||
|
{
|
||||||
|
var id = TrackedOperationId.New();
|
||||||
|
await ParkCachedCallAsync(id);
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||||
|
actor.Tell(new DiscardParkedOperation("corr-2", id));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||||
|
Assert.True(ack.Applied);
|
||||||
|
Assert.Equal("corr-2", ack.CorrelationId);
|
||||||
|
|
||||||
|
var msg = await _storage.GetMessageByIdAsync(id.ToString());
|
||||||
|
Assert.Null(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetryParkedOperation_UnknownOperation_IsSafeNoOp()
|
||||||
|
{
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||||
|
actor.Tell(new RetryParkedOperation("corr-3", TrackedOperationId.New()));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||||
|
// No parked row matched — definitive "nothing to do", not an error.
|
||||||
|
Assert.False(ack.Applied);
|
||||||
|
Assert.Equal("corr-3", ack.CorrelationId);
|
||||||
|
Assert.Null(ack.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RetryParkedOperation_NonParkedOperation_IsSafeNoOpAndDoesNotCorrupt()
|
||||||
|
{
|
||||||
|
// Enqueue a cached call but DO NOT park it — it stays Pending.
|
||||||
|
var id = TrackedOperationId.New();
|
||||||
|
_service.RegisterDeliveryHandler(
|
||||||
|
StoreAndForwardCategory.ExternalSystem, _ => throw new HttpRequestException("fails"));
|
||||||
|
await _service.EnqueueAsync(
|
||||||
|
StoreAndForwardCategory.ExternalSystem, "ERP.GetOrder", """{}""",
|
||||||
|
maxRetries: 5, messageId: id.ToString());
|
||||||
|
|
||||||
|
var before = await _storage.GetMessageByIdAsync(id.ToString());
|
||||||
|
Assert.Equal(StoreAndForwardMessageStatus.Pending, before!.Status);
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||||
|
actor.Tell(new RetryParkedOperation("corr-4", id));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||||
|
// The row is Pending, not Parked — Retry must be a no-op, not a mutation.
|
||||||
|
Assert.False(ack.Applied);
|
||||||
|
|
||||||
|
var after = await _storage.GetMessageByIdAsync(id.ToString());
|
||||||
|
Assert.NotNull(after);
|
||||||
|
Assert.Equal(StoreAndForwardMessageStatus.Pending, after!.Status);
|
||||||
|
// retry_count untouched — a Parked-only Retry must not reset a live row.
|
||||||
|
Assert.Equal(before.RetryCount, after.RetryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardParkedOperation_NonParkedOperation_IsSafeNoOp()
|
||||||
|
{
|
||||||
|
var id = TrackedOperationId.New();
|
||||||
|
_service.RegisterDeliveryHandler(
|
||||||
|
StoreAndForwardCategory.ExternalSystem, _ => throw new HttpRequestException("fails"));
|
||||||
|
await _service.EnqueueAsync(
|
||||||
|
StoreAndForwardCategory.ExternalSystem, "ERP.GetOrder", """{}""",
|
||||||
|
maxRetries: 5, messageId: id.ToString());
|
||||||
|
|
||||||
|
var actor = Sys.ActorOf(Props.Create(() => new ParkedMessageHandlerActor(_service, "site-1")));
|
||||||
|
actor.Tell(new DiscardParkedOperation("corr-5", id));
|
||||||
|
|
||||||
|
var ack = ExpectMsg<ParkedOperationActionAck>();
|
||||||
|
Assert.False(ack.Applied);
|
||||||
|
|
||||||
|
// The Pending row must NOT have been deleted by a Parked-only Discard.
|
||||||
|
var after = await _storage.GetMessageByIdAsync(id.ToString());
|
||||||
|
Assert.NotNull(after);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user