10 Commits

Author SHA1 Message Date
Joseph Doherty d34f536220 fix(centralui): stabilise Site Calls + Audit grid Playwright E2E
Three Playwright E2E failures, all test-side timing/data bugs (no
feature defects found):

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

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

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

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

- wwwroot/js/audit-grid.js: dependency-free helper — pointer-driven
  resize handles, native HTML5 drag-and-drop reorder, and a
  sessionStorage save/load wrapper (mirrors treeview-storage.js).
- AuditResultsGrid: renders a resize handle per <th>, makes headers
  draggable, applies persisted widths via a --audit-col-width custom
  property, and wires reorder into the existing ColumnOrder /
  OrderedColumns() mechanism. JS-invokable OnColumnResized /
  OnColumnReordered persist + re-render. A stored order naming an
  unknown column degrades gracefully (drops unknown keys, appends
  missing columns in default order); widths clamp to a 64px minimum.
- AuditResultsGrid.razor.css: subtle scoped styling for the resize
  handle affordance and the reorder drop-target highlight.
- App.razor references audit-grid.js alongside the other scripts.
- Tests: 6 new bUnit tests for the load/apply/persist logic and
  graceful degradation; a new AuditGridColumnTests Playwright suite
  for the drag UX + reload persistence. Audit page bUnit tests set
  loose JSInterop mode since the grid now calls into audit-grid.js.
2026-05-21 06:27:46 -04:00
Joseph Doherty f64a7aed02 refactor(audit): consolidate query-param parsers; widen CLI export to multi-value 2026-05-21 05:37:06 -04:00
Joseph Doherty 2a76be1f94 feat(audit): multi-value filters across ManagementService, CLI and Central UI 2026-05-21 05:27:17 -04:00
Joseph Doherty 37c7a0e5ac feat(auditlog): multi-value AuditLogQueryFilter dimensions 2026-05-21 05:15:51 -04:00
Joseph Doherty b3b02a8cb6 fix(centralui): apply status/stuck query-string filters on the Site Calls page 2026-05-21 05:08:50 -04:00
Joseph Doherty 44f1ee372a feat(centralui): Site Call KPI tiles on the Health dashboard 2026-05-21 05:04:16 -04:00
46 changed files with 2604 additions and 274 deletions
@@ -11,12 +11,17 @@
>
> **Deferred to v1.x (out of scope, intentionally not implemented):** hash-chain tamper
> evidence (`audit verify-chain` ships as a no-op stub), Parquet export (`format=parquet`
> returns HTTP 501), per-channel retention overrides. **Deferred follow-ups noted during
> implementation:** the real site→central gRPC push client (M6 wired the pull RPC + a mockable
> push seam; `NoOpSiteStreamAuditClient` remains the production binding); consolidation of the
> 4 DTO mapper copies; Site Calls UI page + its Audit drill-in; multi-value filter dimensions
> (`AuditLogQueryFilter` is single-value per dimension, so UI chips / CLI flags collapse to the
> first value); audit-results-grid drag resize/reorder UX.
> returns HTTP 501), per-channel retention overrides. **Follow-ups noted during
> implementation — now complete:** the five follow-ups deferred above (the real
> site→central push client; consolidation of the 4 DTO mapper copies; the Site Calls UI
> page + its Audit drill-in; multi-value filter dimensions; audit-results-grid drag
> resize/reorder UX) were all implemented on the `feature/audit-log-followups` branch
> per `docs/plans/2026-05-21-audit-log-followups.md`. The site→central transport shipped
> as a **ClusterClient-based push** (`ClusterClientSiteAuditClient`, reusing the same
> ClusterClient command/control transport notifications use) rather than the gRPC push
> originally sketched here — `ClusterClientSiteAuditClient` is now the production binding
> for site roles, with `NoOpSiteStreamAuditClient` retained only for central/test
> composition roots; and `AuditLogQueryFilter` is now multi-value per dimension.
>
> **For Claude:** REQUIRED SUB-SKILL FLOW per milestone: `brainstorming` → `writing-plans` → `subagent-driven-development`. Use `docs/requirements/Component-AuditLog.md` + `alog.md` as the spec; this document is the roadmap that sequences milestones and locks acceptance criteria for each. **M1 carries full TDD-level task detail; M2M8 are milestone-shape detail and will be expanded into bite-sized plans by their own writing-plans pass when their turn comes.**
@@ -1,17 +1,17 @@
{
"planPath": "docs/plans/2026-05-21-audit-log-followups.md",
"tasks": [
{"id": 33, "subject": "Task 0: Prep — feature branch", "status": "pending"},
{"id": 34, "subject": "Task 1: Audit push — central ingest routing over ClusterClient", "status": "pending", "blockedBy": [33]},
{"id": 35, "subject": "Task 2: Audit push — real site client, Host wiring, integration test", "status": "pending", "blockedBy": [34]},
{"id": 36, "subject": "Task 3: Consolidate the duplicated audit DTO mappers", "status": "pending", "blockedBy": [33]},
{"id": 37, "subject": "Task 4: Site Call Audit — query / KPI / detail backend", "status": "pending", "blockedBy": [33]},
{"id": 38, "subject": "Task 5: Site Call Audit — Retry/Discard relay to owning site", "status": "pending", "blockedBy": [37]},
{"id": 39, "subject": "Task 6: Site Calls UI page + nav + Audit drill-in", "status": "pending", "blockedBy": [37, 38]},
{"id": 40, "subject": "Task 7: Site Call KPI tiles + Health dashboard integration", "status": "pending", "blockedBy": [37]},
{"id": 41, "subject": "Task 8: Multi-value AuditLogQueryFilter — contract + repository", "status": "pending", "blockedBy": [33]},
{"id": 42, "subject": "Task 9: Multi-value filters — ManagementService, CLI, Central UI", "status": "pending", "blockedBy": [41]},
{"id": 43, "subject": "Task 10: Audit results grid — column resize + reorder UX", "status": "pending", "blockedBy": [33]}
{"id": 33, "subject": "Task 0: Prep — feature branch", "status": "completed"},
{"id": 34, "subject": "Task 1: Audit push — central ingest routing over ClusterClient", "status": "completed", "blockedBy": [33]},
{"id": 35, "subject": "Task 2: Audit push — real site client, Host wiring, integration test", "status": "completed", "blockedBy": [34]},
{"id": 36, "subject": "Task 3: Consolidate the duplicated audit DTO mappers", "status": "completed", "blockedBy": [33]},
{"id": 37, "subject": "Task 4: Site Call Audit — query / KPI / detail backend", "status": "completed", "blockedBy": [33]},
{"id": 38, "subject": "Task 5: Site Call Audit — Retry/Discard relay to owning site", "status": "completed", "blockedBy": [37]},
{"id": 39, "subject": "Task 6: Site Calls UI page + nav + Audit drill-in", "status": "completed", "blockedBy": [37, 38]},
{"id": 40, "subject": "Task 7: Site Call KPI tiles + Health dashboard integration", "status": "completed", "blockedBy": [37]},
{"id": 41, "subject": "Task 8: Multi-value AuditLogQueryFilter — contract + repository", "status": "completed", "blockedBy": [33]},
{"id": 42, "subject": "Task 9: Multi-value filters — ManagementService, CLI, Central UI", "status": "completed", "blockedBy": [41]},
{"id": 43, "subject": "Task 10: Audit results grid — column resize + reorder UX", "status": "completed", "blockedBy": [33]}
],
"lastUpdated": "2026-05-21T07:30:00Z"
"lastUpdated": "2026-05-21T12:00:00Z"
}
@@ -34,15 +34,17 @@ namespace ScadaLink.AuditLog.Site.Telemetry;
/// returns normally.
/// </para>
/// <para>
/// <b>Wire push deferred to M6.</b> M3 keeps this forwarder synchronous
/// against the local stores: there is no site→central gRPC channel yet, so
/// the <see cref="ISiteStreamAuditClient.IngestCachedTelemetryAsync"/> RPC
/// is registered on the interface (Bundle E1) but the production binding
/// remains <c>NoOpSiteStreamAuditClient</c>. Once M6 wires a real client the
/// drain pattern from <c>SiteAuditTelemetryActor</c> can be reused — the
/// <c>AuditEvent</c> rows already live in SQLite tagged
/// <see cref="AuditForwardState.Pending"/>, so a single drain loop sweeps
/// both M2 and M3 emissions.
/// <b>Local-write only — the wire push is the drain actor's job.</b> This
/// forwarder is deliberately synchronous against the two site-local SQLite
/// stores and never pushes to central itself. The site→central transport is
/// now live: <c>ClusterClientSiteAuditClient</c> is the production binding of
/// <see cref="ISiteStreamAuditClient"/> on site roles (with
/// <c>NoOpSiteStreamAuditClient</c> retained only for central/test composition
/// roots). The push happens out-of-band: <see cref="SiteAuditTelemetryActor"/>
/// sweeps the <c>AuditEvent</c> rows this forwarder wrote — they live in SQLite
/// tagged <see cref="AuditForwardState.Pending"/> — and drains them to central
/// via that client. A single drain loop therefore covers both the audit-only
/// emissions and the cached-call emissions this forwarder produces.
/// </para>
/// </remarks>
public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
+62 -16
View File
@@ -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 untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound)" };
// --channel/--kind/--status/--site are multi-valued: System.CommandLine accepts
// both repeated tokens (--channel A --channel B) and, with
// AllowMultipleArgumentsPerToken, a single token carrying several values
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
var channelOption = new Option<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
AllowMultipleArgumentsPerToken = true,
};
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve)" };
var kindOption = new Option<string[]>("--kind")
{
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
AllowMultipleArgumentsPerToken = true,
};
kindOption.AcceptOnlyFromAmong(
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
var statusOption = new Option<string?>("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped)" };
var statusOption = new Option<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
AllowMultipleArgumentsPerToken = true,
};
statusOption.AcceptOnlyFromAmong(
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
@@ -74,10 +94,10 @@ public static class AuditCommands
{
Since = result.GetValue(sinceOption),
Until = result.GetValue(untilOption),
Channel = result.GetValue(channelOption),
Kind = result.GetValue(kindOption),
Status = result.GetValue(statusOption),
Site = result.GetValue(siteOption),
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption),
CorrelationId = result.GetValue(correlationIdOption),
@@ -108,10 +128,36 @@ public static class AuditCommands
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel" };
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" };
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
// --channel/--kind/--status/--site are multi-valued — same shape as the
// `query` subcommand: repeated tokens (--channel A --channel B) and, with
// AllowMultipleArgumentsPerToken, a single token carrying several values
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
var channelOption = new Option<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
AllowMultipleArgumentsPerToken = true,
};
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
var kindOption = new Option<string[]>("--kind")
{
Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable",
AllowMultipleArgumentsPerToken = true,
};
kindOption.AcceptOnlyFromAmong(
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend",
"NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve");
var statusOption = new Option<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable",
AllowMultipleArgumentsPerToken = true,
};
statusOption.AcceptOnlyFromAmong(
"Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped");
var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
@@ -142,10 +188,10 @@ public static class AuditCommands
Until = result.GetValue(untilOption)!,
Format = result.GetValue(formatExportOption)!,
Output = result.GetValue(outputOption)!,
Channel = result.GetValue(channelOption),
Kind = result.GetValue(kindOption),
Status = result.GetValue(statusOption),
Site = result.GetValue(siteOption),
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption),
};
@@ -6,6 +6,10 @@ namespace ScadaLink.CLI.Commands;
/// <summary>
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
/// Bundle B <c>GET /api/audit/export</c> parameters.
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set, exactly like
/// the <c>audit query</c> subcommand.
/// </summary>
public sealed class AuditExportArgs
{
@@ -13,10 +17,10 @@ public sealed class AuditExportArgs
public string Until { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public string Output { get; set; } = string.Empty;
public string? Channel { get; set; }
public string? Kind { get; set; }
public string? Status { get; set; }
public string? Site { get; set; }
public string[] Channel { get; set; } = Array.Empty<string>();
public string[] Kind { get; set; } = Array.Empty<string>();
public string[] Status { get; set; } = Array.Empty<string>();
public string[] Site { get; set; } = Array.Empty<string>();
public string? Target { get; set; }
public string? Actor { get; set; }
}
@@ -31,7 +35,11 @@ public static class AuditExportHelpers
/// <summary>
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
/// time window + format, plus optional filters. Time-specs are resolved via
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>.
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>. The multi-valued
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
/// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
/// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
/// <see cref="AuditQueryHelpers.BuildQueryString"/>.
/// </summary>
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
{
@@ -43,13 +51,21 @@ public static class AuditExportHelpers
parts.Add($"{key}={Uri.EscapeDataString(value)}");
}
void AddEach(string key, IReadOnlyList<string> values)
{
foreach (var value in values)
{
Add(key, value);
}
}
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
Add("format", args.Format);
Add("channel", args.Channel);
Add("kind", args.Kind);
Add("status", args.Status);
Add("sourceSiteId", args.Site);
AddEach("channel", args.Channel);
AddEach("kind", args.Kind);
AddEach("status", args.Status);
AddEach("sourceSiteId", args.Site);
Add("target", args.Target);
Add("actor", args.Actor);
+34 -13
View File
@@ -9,15 +9,18 @@ namespace ScadaLink.CLI.Commands;
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
/// are multi-valued — each supplied value becomes a repeated query-string param so
/// the server's multi-value <c>IN (…)</c> filter sees the full set.
/// </summary>
public sealed class AuditQueryArgs
{
public string? Since { get; set; }
public string? Until { get; set; }
public string? Channel { get; set; }
public string? Kind { get; set; }
public string? Status { get; set; }
public string? Site { get; set; }
public string[] Channel { get; set; } = Array.Empty<string>();
public string[] Kind { get; set; } = Array.Empty<string>();
public string[] Status { get; set; } = Array.Empty<string>();
public string[] Site { get; set; } = Array.Empty<string>();
public string? Target { get; set; }
public string? Actor { get; set; }
public string? CorrelationId { get; set; }
@@ -73,8 +76,11 @@ public static class AuditQueryHelpers
/// <summary>
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
/// args plus an optional keyset cursor. Unset filters are omitted. <c>--errors-only</c>
/// maps to <c>status=Failed</c> (the server takes a single status value).
/// args plus an optional keyset cursor. Unset filters are omitted. The multi-valued
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
/// repeated query-string key per value (e.g. <c>channel=A&amp;channel=B</c>) so the
/// server's multi-value <c>IN (…)</c> filter receives the full set. <c>--errors-only</c>
/// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
/// </summary>
public static string BuildQueryString(
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
@@ -87,20 +93,35 @@ public static class AuditQueryHelpers
parts.Add($"{key}={Uri.EscapeDataString(value)}");
}
void AddEach(string key, IReadOnlyList<string> values)
{
foreach (var value in values)
{
Add(key, value);
}
}
if (!string.IsNullOrWhiteSpace(args.Since))
Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(args.Until))
Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture));
Add("channel", args.Channel);
Add("kind", args.Kind);
AddEach("channel", args.Channel);
AddEach("kind", args.Kind);
// --errors-only is a convenience shorthand for the single-value Failed status
// filter. The server's status filter accepts one value, so --errors-only and an
// explicit --status are mutually exclusive in effect; --errors-only wins.
Add("status", args.ErrorsOnly ? "Failed" : args.Status);
// --errors-only is a convenience shorthand for the Failed status filter. The
// server's status filter is multi-value, but --errors-only stays a single-status
// override: it pins status=Failed and supersedes any explicit --status values.
if (args.ErrorsOnly)
{
Add("status", "Failed");
}
else
{
AddEach("status", args.Status);
}
Add("sourceSiteId", args.Site);
AddEach("sourceSiteId", args.Site);
Add("target", args.Target);
Add("actor", args.Actor);
Add("correlationId", args.CorrelationId);
+9 -4
View File
@@ -1078,10 +1078,10 @@ scadalink --url <url> audit query [options]
|--------|----------|---------|-------------|
| `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 |
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`) |
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`) |
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`) |
| `--site` | no | — | Filter by source site ID |
| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`); repeatable — multiple values are OR-combined |
| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`); repeatable — multiple values are OR-combined |
| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`); repeatable — multiple values are OR-combined |
| `--site` | no | — | Filter by source site ID; repeatable — multiple values are OR-combined |
| `--target` | no | — | Filter by target (external system, DB connection, notification list) |
| `--actor` | no | — | Filter by actor |
| `--correlation-id` | no | — | Filter by correlation ID |
@@ -1090,6 +1090,11 @@ scadalink --url <url> audit query [options]
| `--all` | no | `false` | Fetch every page, following the keyset cursor |
| `--format` | no | `json` | Output format: `json` (JSONL, one event per line) or `table` |
The `--channel`/`--kind`/`--status`/`--site` filters accept multiple values —
either as repeated flags (`--channel ApiOutbound --channel DbOutbound`) or
space-separated after one flag (`--channel ApiOutbound DbOutbound`). Values
within one filter are OR-combined; the different filters are AND-combined.
With `--format table`, events render as an aligned text table with columns
`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`,
`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With
@@ -74,34 +74,27 @@ public static class AuditExportEndpoints
}
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>.
/// Unknown enum names / un-parseable Guids / dates are silently dropped
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>).
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c> dimensions are
/// multi-value: a repeated query param yields a multi-element filter list, a
/// single param a one-element list. Unknown enum names / un-parseable Guids /
/// dates are silently dropped (same lax contract as
/// <c>AuditLogPage.ApplyQueryStringFilters</c>) — an unparseable value within
/// a repeated set is dropped, not the whole set.
/// </summary>
/// <remarks>
/// This endpoint reads the source-site filter from the <c>site</c> query key,
/// whereas the ManagementService export endpoint reads it as
/// <c>sourceSiteId</c>. The divergence is deliberate — each endpoint matches
/// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
/// </remarks>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
var sites = AuditQueryParamParsers.ParseStringList(query["site"]);
AuditKind? kind = null;
if (query.TryGetValue("kind", out var kindValues)
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
{
kind = parsedKind;
}
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
string? site = TrimToNullable(query, "site");
string? target = TrimToNullable(query, "target");
string? actor = TrimToNullable(query, "actor");
@@ -116,10 +109,10 @@ public static class AuditExportEndpoints
DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: site,
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId,
@@ -15,20 +15,20 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// </para>
///
/// <para>
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is single-value
/// per dimension today; the chip multi-selects therefore collapse to the FIRST
/// selected chip when the model is published via <see cref="ToFilter"/>. That is a
/// deliberate Bundle B scope decision — the chip UI is preserved so a follow-up can
/// either repeat the query per chip or widen the filter contract without rewriting
/// the form. Instance and Script free-text are also UI-only today: the underlying
/// filter has no matching columns, so they are dropped during collapse.
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is multi-value
/// per dimension: the chip multi-selects map straight through to the
/// <c>Channels</c> / <c>Kinds</c> / <c>Statuses</c> / <c>SourceSiteIds</c> filter
/// lists when the model is published via <see cref="ToFilter"/> — an empty set means
/// "do not constrain". Instance and Script free-text remain UI-only: the underlying
/// filter has no matching columns, so they are dropped when the model is published.
/// </para>
///
/// <para>
/// The Errors-only toggle is a convenience: when true AND no explicit Status chips
/// are selected, the collapsed filter pins <see cref="AuditStatus.Failed"/> (the
/// first of {Failed, Parked, Discarded}). When Status chips ARE selected the toggle
/// is a no-op — the explicit Status filter wins.
/// are selected, <see cref="ToFilter"/> targets the full error-status set
/// {<see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
/// <see cref="AuditStatus.Discarded"/>}. When Status chips ARE selected the toggle
/// is a no-op — the explicit Status chips win.
/// </para>
/// </summary>
public sealed class AuditQueryModel
@@ -104,20 +104,21 @@ public sealed class AuditQueryModel
}
/// <summary>
/// Collapses this UI model to the repository's single-value filter.
/// See class doc for the multi-select → single-value contract.
/// Publishes this UI model as the repository's multi-value filter: each chip
/// multi-select maps straight through to its filter list (an empty set yields
/// <c>null</c> — "do not constrain"). See class doc for the Errors-only rule.
/// </summary>
public AuditLogQueryFilter ToFilter(DateTime utcNow)
{
var status = ResolveStatus();
var statuses = ResolveStatuses();
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
return new AuditLogQueryFilter(
Channel: Channels.Count > 0 ? Channels.First() : null,
Kind: Kinds.Count > 0 ? Kinds.First() : null,
Status: status,
SourceSiteId: SiteIdentifiers.Count > 0 ? SiteIdentifiers.First() : null,
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
Statuses: statuses,
SourceSiteIds: SiteIdentifiers.Count > 0 ? SiteIdentifiers.ToArray() : null,
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
@@ -125,20 +126,22 @@ public sealed class AuditQueryModel
ToUtc: toUtc);
}
private AuditStatus? ResolveStatus()
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>
private static readonly AuditStatus[] ErrorStatuses =
{ AuditStatus.Failed, AuditStatus.Parked, AuditStatus.Discarded };
private IReadOnlyList<AuditStatus>? ResolveStatuses()
{
if (Statuses.Count > 0)
{
// Explicit chips win — Errors-only is a no-op.
return Statuses.First();
return Statuses.ToArray();
}
if (ErrorsOnly)
{
// Single-value filter contract: Failed is the lead non-success status.
// When the filter widens to multi-value the full {Failed, Parked, Discarded}
// set will flow through.
return AuditStatus.Failed;
// Multi-value filter: Errors-only targets the full non-success set.
return ErrorStatuses;
}
return null;
@@ -12,12 +12,26 @@
}
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<table class="table table-sm table-hover align-middle" @ref="_tableRef">
<thead class="table-light">
<tr>
@foreach (var col in OrderedColumns())
{
<th data-test="col-header-@col.Key">@col.Label</th>
// @key keeps Blazor reusing one DOM node per column across
// re-renders (reorder/resize), so audit-grid.js binds drag
// listeners exactly once per <th> and never leaks them onto
// discarded nodes — the __auditGridCellBound guard relies on
// this node stability to be fully sound.
<th class="audit-grid-th"
@key="col.Key"
data-test="col-header-@col.Key"
data-col-key="@col.Key"
style="@ColumnWidthStyle(col.Key)">
@col.Label
<span class="audit-grid-resize-handle"
data-test="col-resize-@col.Key"
aria-hidden="true"></span>
</th>
}
</tr>
</thead>
@@ -48,7 +62,7 @@
@onclick="() => HandleRowClick(row)">
@foreach (var col in OrderedColumns())
{
<td>
<td class="audit-grid-td" style="@ColumnWidthStyle(col.Key)">
@RenderCell(col.Key, row)
</td>
}
@@ -1,4 +1,6 @@
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
@@ -14,12 +16,15 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// source without standing up EF Core.
///
/// <para>
/// <b>Column model.</b> Each column has a stable string key; the visible order
/// is the <see cref="ColumnOrder"/> parameter. M7 scope: the column-model
/// framework is in place but resize / drag-reorder UX is intentionally NOT
/// implemented — the full spec calls for persisted-per-user reordering and
/// resizing, which M7.x can ship without rewriting the column model. Resizing
/// today is CSS-based via Bootstrap's <c>.table-responsive</c> wrapper.
/// <b>Column model.</b> Each column has a stable string key. The default
/// visible order is the <see cref="ColumnOrder"/> parameter (or the spec
/// order from Component-AuditLog.md §10 when the parameter is null). On top of
/// that default the grid layers a per-browser override: drag-to-reorder and
/// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column
/// widths to <c>sessionStorage</c>, and the grid restores them on first
/// render. A stored order that names an unknown/removed column degrades
/// gracefully — unknown keys are dropped, missing columns appended in default
/// order — so it never throws.
/// </para>
///
/// <para>
@@ -32,11 +37,28 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
/// end" signal for keyset paging without a count query.
/// </para>
///
/// <para>
/// <b>Accessibility.</b> Column resize and reorder are mouse/pointer-only —
/// they use a pointer-driven resize handle and native HTML5 drag-and-drop with
/// no keyboard equivalent and no ARIA for the reorder. This is a conscious
/// scope decision for an internal tool, not an oversight: only the column-
/// <i>customisation</i> gesture is mouse-only. The persisted layout itself
/// renders as plain HTML, so keyboard and assistive-technology users still get
/// a fully readable, navigable grid.
/// </para>
/// </summary>
public partial class AuditResultsGrid
public partial class AuditResultsGrid : IAsyncDisposable
{
private const int DefaultPageSize = 100;
/// <summary>Minimum persisted column width — mirrors <c>auditGrid.minWidth</c>.</summary>
private const int MinColumnWidthPx = 64;
/// <summary>sessionStorage keys (namespaced under <c>auditGrid:</c> by the JS helper).</summary>
private const string ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths";
private readonly List<AuditEvent> _rows = new();
private int _pageNumber = 1;
private bool _loading;
@@ -44,6 +66,18 @@ public partial class AuditResultsGrid
private AuditLogQueryFilter? _activeFilter;
[Inject] private IJSRuntime JS { get; set; } = default!;
private ElementReference _tableRef;
private DotNetObjectReference<AuditResultsGrid>? _selfRef;
// Effective column state. _columnOrder is the live display order (seeded
// from the ColumnOrder parameter / spec default, then overridden by any
// persisted sessionStorage order). _columnWidths holds per-key pixel
// widths from a prior resize; absent keys render at auto width.
private List<string>? _columnOrder;
private readonly Dictionary<string, int> _columnWidths = new();
/// <summary>
/// Filter to apply. When this parameter changes the grid resets to page 1 and
/// reissues the query — that's the contract the parent page relies on so the
@@ -75,6 +109,9 @@ public partial class AuditResultsGrid
/// <c>data-test</c> + the column-order parameter); the label is the user-facing
/// header text. Mirrors Component-AuditLog.md §10.
/// </summary>
// Label intentionally equals Key for every column today; the separate Label
// field is future-proofing for humanised headers (e.g. "Occurred (UTC)") —
// populating it is a deliberate later change, out of scope here.
private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
{
("OccurredAtUtc", "OccurredAtUtc"),
@@ -90,24 +127,57 @@ public partial class AuditResultsGrid
};
private IReadOnlyList<(string Key, string Label)> OrderedColumns()
=> ResolveOrder(_columnOrder ?? ColumnOrder);
/// <summary>
/// Resolves a candidate list of column keys into the concrete display
/// columns. Degrades gracefully so a stale persisted order is never fatal:
/// unknown keys are dropped, and any column not named in the candidate
/// list is appended in its default (spec) position. A null/empty candidate
/// yields the full default order.
/// </summary>
private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList<string>? candidate)
{
if (ColumnOrder is null || ColumnOrder.Count == 0)
if (candidate is null || candidate.Count == 0)
{
return AllColumns;
}
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
var ordered = new List<(string Key, string Label)>(ColumnOrder.Count);
foreach (var key in ColumnOrder)
var ordered = new List<(string Key, string Label)>(AllColumns.Count);
var seen = new HashSet<string>();
foreach (var key in candidate)
{
if (byKey.TryGetValue(key, out var col))
// Drop unknown keys (removed/renamed columns) and any duplicates.
if (byKey.TryGetValue(key, out var col) && seen.Add(key))
{
ordered.Add(col);
}
}
return ordered.Count == 0 ? AllColumns : ordered;
// Append any columns the candidate omitted, in default order, so a
// newly-added column still appears after a restore of an older order.
foreach (var col in AllColumns)
{
if (seen.Add(col.Key))
{
ordered.Add(col);
}
}
return ordered;
}
/// <summary>
/// Inline style for a column's cells: emits the <c>--audit-col-width</c>
/// custom property the scoped stylesheet reads, or an empty string when
/// the column has no persisted width (auto layout).
/// </summary>
private string ColumnWidthStyle(string key)
=> _columnWidths.TryGetValue(key, out var width)
? $"--audit-col-width: {width}px;"
: string.Empty;
protected override async Task OnParametersSetAsync()
{
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter
@@ -180,6 +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
{
AuditStatus.Delivered => "badge bg-success",
@@ -0,0 +1,82 @@
/* Audit results grid column resize + reorder UX (#23 follow-ups Task 10).
The base .table classes come from Bootstrap; the rules below add the
resize-handle affordance and the drag-to-reorder drop feedback. The
interaction itself lives in wwwroot/js/audit-grid.js this file is purely
the visual treatment. Internal-tool aesthetic: subtle, no flashy motion. */
/* A persisted width is delivered as the --audit-col-width custom property on
the <th> and matching <td> cells (set inline by the component / by
audit-grid.js during a drag). When present it pins the cell; when absent
the column falls back to Bootstrap auto-layout. The body cells also clip
overflowing text so a narrowed column stays tidy. */
.audit-grid-th[style*="--audit-col-width"],
.audit-grid-td[style*="--audit-col-width"] {
width: var(--audit-col-width);
min-width: var(--audit-col-width);
max-width: var(--audit-col-width);
}
.audit-grid-td[style*="--audit-col-width"] {
overflow: hidden;
text-overflow: ellipsis;
}
/* The header cell hosts the resize handle on its right edge, so it must be a
positioning context. Padding on the right is trimmed so the 6px handle does
not crowd the label text. */
.audit-grid-th {
position: relative;
padding-right: 0.75rem;
/* The whole header is draggable for reorder — a grab cursor signals it. */
cursor: grab;
user-select: none;
white-space: nowrap;
}
.audit-grid-th:active {
cursor: grabbing;
}
/* V resize handle. A thin invisible hit-strip on the right edge: 6px wide
for a comfortable grab target, transparent at rest so the header reads
clean. On hover a hairline primary rule fades in via the inset box-shadow
so the affordance is discoverable without being visually noisy. */
.audit-grid-resize-handle {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: col-resize;
/* Sit above the draggable header so a resize never starts a reorder. */
z-index: 1;
transition: box-shadow 0.08s linear, background-color 0.08s linear;
}
.audit-grid-resize-handle:hover {
/* Hairline rule centred on the strip's right edge. */
box-shadow: inset -2px 0 0 -1px rgba(var(--bs-primary-rgb), 0.55);
background-color: rgba(var(--bs-primary-rgb), 0.06);
}
/* While a drag-resize is in progress the column gets a steady primary rule on
its right edge so the user keeps a clear visual anchor. */
.audit-grid-th.resizing {
box-shadow: inset -2px 0 0 0 var(--bs-primary);
}
.audit-grid-th.resizing .audit-grid-resize-handle {
background-color: rgba(var(--bs-primary-rgb), 0.55);
}
/* V reorder feedback. The dragged header dims slightly; the prospective
drop target gets a left-edge accent rule + a faint info wash, matching the
TreeView drop-target idiom (a quiet, unmistakable cue, not an animation). */
.audit-grid-th.dragging {
opacity: 0.45;
}
.audit-grid-th.drop-target {
background-color: rgba(var(--bs-info-rgb), 0.18);
box-shadow: inset 2px 0 0 0 var(--bs-info);
}
@@ -0,0 +1,60 @@
@*
Site Call Audit (#22) Task 7 — three Health-dashboard KPI tiles for the
Site Call channel: Buffered / Parked / Stuck. Renders Bootstrap card tiles
in a single row, each acting as a navigation link to a pre-filtered Site
Calls report view. The component is purely presentational — the parent page
owns the refresh loop and passes the latest snapshot via the Snapshot
parameter. Mirrors AuditKpiTiles and the Notification Outbox KPI section.
*@
@namespace ScadaLink.CentralUI.Components.Health
@inject NavigationManager Navigation
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Site Calls</h6>
<a class="small" href="/site-calls/report">View details &rarr;</a>
</div>
<div class="row g-3 mb-3">
@* ── Buffered tile ─────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile"
data-test="site-call-kpi-buffered"
@onclick="NavigateToBuffered">
<div class="card-body text-center">
<h3 class="mb-0">@BufferedDisplay</h3>
<small class="text-muted">Buffered</small>
</div>
</button>
</div>
@* ── Stuck tile ────────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @StuckBorderClass"
data-test="site-call-kpi-stuck"
@onclick="NavigateToStuck">
<div class="card-body text-center">
<h3 class="mb-0 @StuckTextClass">@StuckDisplay</h3>
<small class="text-muted">Stuck</small>
</div>
</button>
</div>
@* ── Parked tile ───────────────────────────────────────────────────────── *@
<div class="col-lg-4 col-md-6 col-12">
<button type="button"
class="card h-100 w-100 text-start border-0 shadow-none p-0 site-call-kpi-tile @ParkedBorderClass"
data-test="site-call-kpi-parked"
@onclick="NavigateToParked">
<div class="card-body text-center">
<h3 class="mb-0 @ParkedTextClass">@ParkedDisplay</h3>
<small class="text-muted">Parked</small>
</div>
</button>
</div>
</div>
@if (!IsAvailable && !string.IsNullOrEmpty(ErrorMessage))
{
<div class="text-muted small mb-3">Site Call KPIs unavailable: @ErrorMessage</div>
}
@@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Components;
using ScadaLink.Commons.Messages.Audit;
namespace ScadaLink.CentralUI.Components.Health;
/// <summary>
/// Site Call Audit (#22) Task 7 code-behind for <see cref="SiteCallKpiTiles"/>.
/// Renders three KPI tiles — Buffered, Stuck, Parked — from a
/// <see cref="SiteCallKpiResponse"/> the parent Health dashboard supplies.
/// Tiles act as drill-in links: clicking navigates to <c>/site-calls/report</c>
/// with the relevant query-string filter pre-applied. Mirrors
/// <see cref="AuditKpiTiles"/> and the Notification Outbox KPI section on the
/// Health dashboard.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why purely presentational.</b> The Health dashboard already owns a 10s
/// auto-refresh loop; pushing that into the tile component would either
/// duplicate it (one timer per tile) or awkwardly couple back to the page. The
/// parent passes a fresh <see cref="SiteCallKpiResponse"/> every refresh and the
/// tile component re-renders. This is the same contract <see cref="AuditKpiTiles"/>
/// follows.
/// </para>
/// <para>
/// <b>Snapshot shape.</b> Unlike <see cref="AuditKpiTiles"/> — which takes a
/// dedicated <c>AuditLogKpiSnapshot</c> type — Site Call KPIs travel in the
/// <see cref="SiteCallKpiResponse"/> message itself (it carries the KPI fields
/// directly), so that record doubles as the snapshot here. <see cref="IsAvailable"/>
/// is a separate flag rather than the record's own <c>Success</c> so the parent
/// can also surface a transport failure (an Ask that threw) as unavailable.
/// </para>
/// <para>
/// <b>Threshold borders.</b> Mirrors the Notification Outbox tile pattern: the
/// Parked tile gets a danger border when <c>ParkedCount &gt; 0</c>; the Stuck
/// tile gets a warning border when <c>StuckCount &gt; 0</c>. Buffered is a plain
/// count tile with no threshold colour — a non-zero buffer is normal operation.
/// </para>
/// </remarks>
public partial class SiteCallKpiTiles
{
/// <summary>
/// Latest KPI snapshot. <c>null</c> means the parent has not loaded it yet
/// or the load failed — the tiles render em dashes in that case.
/// </summary>
[Parameter] public SiteCallKpiResponse? Snapshot { get; set; }
/// <summary>
/// True when <see cref="Snapshot"/> is a successful query result. False when
/// the parent's refresh threw, or the response itself reported a fault, and
/// the displayed values should be rendered as em dashes with an error
/// explanation underneath.
/// </summary>
[Parameter] public bool IsAvailable { get; set; }
/// <summary>
/// Optional error message to render underneath the tiles when
/// <see cref="IsAvailable"/> is false. Mirrors how the Notification Outbox
/// section on the Health dashboard surfaces transient KPI failures.
/// </summary>
[Parameter] public string? ErrorMessage { get; set; }
// ── Buffered tile ───────────────────────────────────────────────────────
private string BufferedDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.BufferedCount.ToString("N0")
: "—";
private void NavigateToBuffered()
{
// Buffered is "everything still in flight" — no single status maps to
// it, so the natural drill-in is the unfiltered Site Calls report sorted
// by newest, mirroring how the Audit volume/backlog tiles drop the
// operator on the unfiltered Audit Log grid.
Navigation.NavigateTo("/site-calls/report");
}
// ── Stuck tile ──────────────────────────────────────────────────────────
private string StuckDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.StuckCount.ToString("N0")
: "—";
// Stuck above zero is a warning signal — cached calls that have been
// Pending/Retrying past the stuck-age threshold. Matches the Notification
// Outbox Stuck tile (border-warning when StuckCount > 0).
private string StuckBorderClass =>
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
? "border-warning"
: string.Empty;
private string StuckTextClass =>
IsAvailable && Snapshot is not null && Snapshot.StuckCount > 0
? "text-warning"
: string.Empty;
private void NavigateToStuck()
{
// Drill in with the report's "stuck only" filter pre-applied.
Navigation.NavigateTo("/site-calls/report?stuck=true");
}
// ── Parked tile ─────────────────────────────────────────────────────────
private string ParkedDisplay =>
IsAvailable && Snapshot is not null
? Snapshot.ParkedCount.ToString("N0")
: "—";
// Parked above zero is a danger signal — cached calls that exhausted retries
// and need an operator Retry/Discard. Matches the Notification Outbox Parked
// tile (border-danger when ParkedCount > 0).
private string ParkedBorderClass =>
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
? "border-danger"
: string.Empty;
private string ParkedTextClass =>
IsAvailable && Snapshot is not null && Snapshot.ParkedCount > 0
? "text-danger"
: string.Empty;
private void NavigateToParked()
{
// Drill in pre-filtered to Parked — the report's Status filter accepts
// ?status=Parked and Parked rows carry the Retry/Discard relay actions.
Navigation.NavigateTo("/site-calls/report?status=Parked");
}
}
@@ -19,7 +19,7 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <para>
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, and the UI-only
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
@@ -80,33 +80,27 @@ public partial class AuditLogPage
}
}
string? site = null;
if (query.TryGetValue("site", out var siteValues))
{
var v = siteValues.ToString();
if (!string.IsNullOrWhiteSpace(v))
{
site = v.Trim();
}
}
// site/channel/kind/status accept repeated params for symmetry with the
// multi-value export URL — a single ?site=/?channel=/?kind=/?status=
// drill-in still works (one-element list). Unknown enum names are silently
// dropped. The lax-parse contract is shared with the two export endpoints
// via AuditQueryParamParsers so all three surfaces stay in lockstep.
IReadOnlyList<string>? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site"));
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
IReadOnlyList<AuditChannel>? channels =
AuditQueryParamParsers.ParseEnumList<AuditChannel>(Raw(query, "channel"));
// ?kind= is honored for symmetry with BuildExportUrl, which emits a kind=
// param — a kind drill-in deep link must round-trip back into the filter.
IReadOnlyList<AuditKind>? kinds =
AuditQueryParamParsers.ParseEnumList<AuditKind>(Raw(query, "kind"));
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in
// with ?status=Failed (and operators may craft URLs with Parked/Discarded).
// Unknown values are silently dropped — the page still renders without
// the constraint.
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
IReadOnlyList<AuditStatus>? statuses =
AuditQueryParamParsers.ParseEnumList<AuditStatus>(Raw(query, "status"));
// Instance is UI-only — the filter contract has no matching column, so we
// pass it as a separate seam to the filter bar.
@@ -123,20 +117,33 @@ public partial class AuditLogPage
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
// because the filter contract has no instance column — the user still needs
// to refine + Apply for those.
if (correlationId is null && target is null && actor is null && site is null && channel is null && status is null)
if (correlationId is null && target is null && actor is null
&& sites is null && channels is null && kinds is null && statuses is null)
{
return;
}
_currentFilter = new AuditLogQueryFilter(
Channel: channel,
Status: status,
SourceSiteId: site,
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId);
}
/// <summary>
/// Extracts the raw repeated values for one query-string key, returning
/// <c>null</c> when the key is absent so the shared
/// <see cref="AuditQueryParamParsers"/> sees the same absent-vs-present
/// distinction the ASP.NET <c>IQueryCollection</c> callers do.
/// <c>StringValues</c> is itself an <c>IEnumerable&lt;string?&gt;</c>.
/// </summary>
private static IEnumerable<string?>? Raw(
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
query.TryGetValue(key, out var values) ? (IEnumerable<string?>)values : null;
private void HandleFilterChanged(AuditLogQueryFilter filter)
{
// Always reassign — the grid keys reloads on reference change, so even a
@@ -180,22 +187,42 @@ public partial class AuditLogPage
return basePath;
}
var parts = new List<KeyValuePair<string, string?>>(9);
if (filter.Channel is { } ch)
// No capacity hint: the dimensions are multi-value, so the part count is
// unbounded by the number of filter fields.
var parts = new List<KeyValuePair<string, string?>>();
// Task 9: the filter dimensions are multi-value end-to-end. Emit ONE
// repeated query-string key per selected value (channel=A&channel=B); the
// export endpoint's ParseFilter reads the full repeated set.
if (filter.Channels is { Count: > 0 } channels)
{
parts.Add(new("channel", ch.ToString()));
foreach (var channel in channels)
{
parts.Add(new("channel", channel.ToString()));
}
}
if (filter.Kind is { } kind)
if (filter.Kinds is { Count: > 0 } kinds)
{
parts.Add(new("kind", kind.ToString()));
foreach (var kind in kinds)
{
parts.Add(new("kind", kind.ToString()));
}
}
if (filter.Status is { } status)
if (filter.Statuses is { Count: > 0 } statuses)
{
parts.Add(new("status", status.ToString()));
foreach (var status in statuses)
{
parts.Add(new("status", status.ToString()));
}
}
if (!string.IsNullOrWhiteSpace(filter.SourceSiteId))
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{
parts.Add(new("site", filter.SourceSiteId));
foreach (var site in sourceSiteIds)
{
if (!string.IsNullOrWhiteSpace(site))
{
parts.Add(new("site", site));
}
}
}
if (!string.IsNullOrWhiteSpace(filter.Target))
{
@@ -8,6 +8,7 @@
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.HealthMonitoring
@using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Commons.Messages.Audit
@using ScadaLink.Communication
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
@@ -60,6 +61,12 @@
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
}
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel
(buffered / stuck / parked). Refreshed alongside the site states. *@
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
IsAvailable="@_siteCallKpiAvailable"
ErrorMessage="@_siteCallKpiError" />
@* Audit Log (#23) M7 Bundle E — three KPI tiles for the Audit channel
(volume / error rate / backlog). Refreshed alongside the site states. *@
<AuditKpiTiles Snapshot="@_auditKpi"
@@ -364,6 +371,13 @@
private bool _auditKpiAvailable;
private string? _auditKpiError;
// Site Call Audit (#22) Task 7 — Site Call KPI tiles. Point-in-time counts
// from the central SiteCalls table, fetched alongside the site states. The
// SiteCallKpiResponse message doubles as the snapshot the tile takes.
private SiteCallKpiResponse? _siteCallKpi;
private bool _siteCallKpiAvailable;
private string? _siteCallKpiError;
private static bool SiteHasActiveErrors(SiteHealthState state)
{
var report = state.LatestReport;
@@ -401,6 +415,7 @@
{
_siteStates = HealthAggregator.GetAllSiteStates();
await LoadOutboxKpis();
await LoadSiteCallKpis();
await LoadAuditKpis();
}
@@ -429,6 +444,36 @@
}
}
// Site Call KPI loader: wraps the service call so a transient fault degrades
// the three Site Call tiles to em dashes with an inline error rather than
// killing the dashboard. Mirrors LoadOutboxKpis's error handling shape — a
// response with Success == false (repository fault) and an Ask that threw
// (transport fault) both collapse to "unavailable".
private async Task LoadSiteCallKpis()
{
try
{
var response = await CommunicationService.GetSiteCallKpisAsync(
new SiteCallKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_siteCallKpi = response;
_siteCallKpiAvailable = true;
_siteCallKpiError = null;
}
else
{
_siteCallKpiAvailable = false;
_siteCallKpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_siteCallKpiAvailable = false;
_siteCallKpiError = $"KPI query failed: {ex.Message}";
}
}
// Tiles show the numeric KPI when available, or an em dash when the outbox
// KPI query failed — matching how the page renders other unavailable data.
private string OutboxTileValue(int value) =>
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
@@ -26,11 +28,32 @@ namespace ScadaLink.CentralUI.Components.Pages.SiteCalls;
/// a relay that never reaches the site is a transient transport condition, surfaced
/// to the operator differently from a generic failure.
/// </para>
///
/// <para>
/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
/// with <c>?status=Parked</c> (Parked tile) or <c>?stuck=true</c> (Stuck tile). On
/// initialization those params seed <see cref="_statusFilter"/> / <see cref="_stuckOnly"/>
/// BEFORE the first <see cref="RefreshAll"/>, so the first grid load is already
/// filtered and the filter card controls reflect the seeded values. Parsing is lax
/// — an absent, blank, or unrecognised value is silently dropped and the page loads
/// unfiltered, mirroring <c>AuditLogPage</c>'s drill-in convention.
/// </para>
/// </summary>
public partial class SiteCallsReport
{
private const int PageSize = 50;
[Inject] private NavigationManager Navigation { get; set; } = null!;
// The Status filter <select> options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
// filter when it matches one of these (case-insensitively); anything else is
// dropped so a hand-crafted bad URL still renders the page unfiltered.
private static readonly string[] ValidStatuses =
{
"Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
};
private ToastNotification _toast = default!;
private List<Site> _sites = new();
@@ -77,9 +100,51 @@ public partial class SiteCallsReport
Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
}
// Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
// grid load is already filtered (and the filter card controls reflect it).
ApplyQueryStringFilters();
await RefreshAll();
}
/// <summary>
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
/// string. <c>?status=&lt;status&gt;</c> seeds <see cref="_statusFilter"/> when it
/// matches a known status (case-insensitive); <c>?stuck=true</c> seeds
/// <see cref="_stuckOnly"/>. Lax parsing — an absent, blank, or unrecognised value
/// is silently dropped, leaving the filter empty (the no-param behaviour).
/// </summary>
private void ApplyQueryStringFilters()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.Count == 0)
{
return;
}
if (query.TryGetValue("status", out var statusValues))
{
var v = statusValues.ToString();
// Round-trip the dropdown's own option strings (the KPI tile emits the
// canonical casing, e.g. ?status=Parked); normalise to that casing so the
// <select> binds. An unrecognised value leaves the filter unset.
var match = ValidStatuses.FirstOrDefault(
s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
if (match is not null)
{
_statusFilter = match;
}
}
if (query.TryGetValue("stuck", out var stuckValues)
&& bool.TryParse(stuckValues.ToString(), out var stuck))
{
_stuckOnly = stuck;
}
}
/// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
private async Task RefreshAll()
{
@@ -0,0 +1,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");
}
}
};
@@ -4,16 +4,20 @@ namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
/// <c>&gt;=</c> / <c>&lt;=</c> respectively. All filter fields are AND-combined.
/// Any field left <c>null</c> means "do not constrain on that column". The
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
/// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
/// list means "do not constrain", and a non-empty list is OR-combined within the
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another.
/// </summary>
public sealed record AuditLogQueryFilter(
AuditChannel? Channel = null,
AuditKind? Kind = null,
AuditStatus? Status = null,
string? SourceSiteId = null,
IReadOnlyList<AuditChannel>? Channels = null,
IReadOnlyList<AuditKind>? Kinds = null,
IReadOnlyList<AuditStatus>? Statuses = null,
IReadOnlyList<string>? SourceSiteIds = null,
string? Target = null,
string? Actor = null,
Guid? CorrelationId = null,
@@ -0,0 +1,79 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Shared lax parsers for the multi-value Audit Log query parameters
/// (<c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c>). The Audit Log filter
/// wire-contract is consumed by three surfaces that MUST stay in lockstep:
/// <list type="bullet">
/// <item>the ManagementService <c>/api/audit/query</c> + <c>/api/audit/export</c>
/// endpoints,</item>
/// <item>the CentralUI <c>/api/centralui/audit/export</c> endpoint, and</item>
/// <item>the CentralUI <c>AuditLogPage</c> query-string drill-in parser.</item>
/// </list>
///
/// <para>
/// Each caller extracts the raw repeated values for a single parameter from its
/// own request type (ASP.NET <c>IQueryCollection</c>, a
/// <c>Dictionary&lt;string, StringValues&gt;</c> from <c>QueryHelpers.ParseQuery</c>,
/// etc.) and passes them here as a plain <see cref="IEnumerable{T}"/> of strings —
/// so this helper carries NO ASP.NET / <c>Microsoft.Extensions.Primitives</c>
/// dependency and can live in <c>ScadaLink.Commons</c>.
/// </para>
///
/// <para>
/// <b>Lax-parse contract.</b> Every value of a repeated parameter is parsed
/// independently; an unparseable or blank element is silently dropped (NO 400)
/// rather than failing the whole set. An empty result collapses to <c>null</c> so
/// the corresponding filter dimension stays unconstrained.
/// </para>
/// </summary>
public static class AuditQueryParamParsers
{
/// <summary>
/// Parses each raw value as <typeparamref name="TEnum"/> (case-insensitive),
/// dropping unparseable values silently. Returns <c>null</c> when
/// <paramref name="rawValues"/> is <c>null</c>, empty, or yields no parseable
/// value — so the filter dimension stays unconstrained.
/// </summary>
public static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IEnumerable<string?>? rawValues)
where TEnum : struct, Enum
{
if (rawValues is null)
{
return null;
}
var parsed = new List<TEnum>();
foreach (var raw in rawValues)
{
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
{
parsed.Add(value);
}
}
return parsed.Count > 0 ? parsed : null;
}
/// <summary>
/// Trims each raw value and drops blank entries. Returns <c>null</c> when
/// <paramref name="rawValues"/> is <c>null</c>, empty, or every value was
/// blank.
/// </summary>
public static IReadOnlyList<string>? ParseStringList(IEnumerable<string?>? rawValues)
{
if (rawValues is null)
{
return null;
}
var parsed = new List<string>();
foreach (var raw in rawValues)
{
if (!string.IsNullOrWhiteSpace(raw))
{
parsed.Add(raw.Trim());
}
}
return parsed.Count > 0 ? parsed : null;
}
}
@@ -355,6 +355,14 @@ public class CommunicationService
/// owning site and replies a <see cref="RetrySiteCallResponse"/> carrying a
/// distinct site-unreachable outcome. Central never mutates the central
/// <c>SiteCalls</c> mirror row.
/// <para>
/// This outer Ask uses <see cref="CommunicationOptions.QueryTimeout"/>
/// (default 30s), which must outlive the inner site relay Ask the
/// <c>SiteCallAuditActor</c> issues with <c>SiteCallAuditOptions.RelayTimeout</c>
/// (default 10s). The inner relay must time out first so its distinct
/// <c>SiteUnreachable</c> outcome reaches us; were this outer Ask to expire
/// first, that outcome would be lost to a generic Ask-timeout exception.
/// </para>
/// </summary>
public async Task<RetrySiteCallResponse> RetrySiteCallAsync(
RetrySiteCallRequest request, CancellationToken cancellationToken = default)
@@ -116,25 +116,28 @@ VALUES
var query = _context.Set<AuditEvent>().AsNoTracking();
if (filter.Channel is { } channel)
// Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
// WHERE 1=0). A non-empty list translates to a SQL IN (…) via EF Core's
// IReadOnlyList<T>.Contains support — server-side, no client-eval.
if (filter.Channels is { Count: > 0 } channels)
{
query = query.Where(e => e.Channel == channel);
query = query.Where(e => channels.Contains(e.Channel));
}
if (filter.Kind is { } kind)
if (filter.Kinds is { Count: > 0 } kinds)
{
query = query.Where(e => e.Kind == kind);
query = query.Where(e => kinds.Contains(e.Kind));
}
if (filter.Status is { } status)
if (filter.Statuses is { Count: > 0 } statuses)
{
query = query.Where(e => e.Status == status);
query = query.Where(e => statuses.Contains(e.Status));
}
if (!string.IsNullOrEmpty(filter.SourceSiteId))
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
{
var siteId = filter.SourceSiteId;
query = query.Where(e => e.SourceSiteId == siteId);
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
}
if (!string.IsNullOrEmpty(filter.Target))
@@ -681,9 +681,10 @@ akka {{
// Per Bundle E's brief: the SiteAuditTelemetryActor takes its
// collaborators through its constructor, so we resolve them from DI
// and pass them in via Props.Create rather than relying on a future
// FactoryProvider. This also lets the M6 follow-up swap the
// NoOpSiteStreamAuditClient registration for the real gRPC client
// without touching this site wiring.
// FactoryProvider. The real site→central client is constructed and
// wired immediately below: a ClusterClientSiteAuditClient (ClusterClient
// transport, not gRPC) replaces the DI-default NoOpSiteStreamAuditClient
// for site roles, without disturbing the rest of this wiring.
var siteAuditOptions = _serviceProvider
.GetRequiredService<IOptions<ScadaLink.AuditLog.Site.Telemetry.SiteAuditTelemetryOptions>>();
var siteAuditQueue = _serviceProvider
+1
View File
@@ -77,6 +77,7 @@
</script>
<script src="/js/treeview-storage.js"></script>
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
<script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -367,32 +367,26 @@ public static class AuditEndpoints
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. Unknown
/// enum names / un-parseable Guids / dates are silently dropped (no 400) —
/// the same lax contract the CentralUI export endpoint uses.
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
/// multi-value: a repeated query param (<c>channel=A&amp;channel=B</c>) yields
/// a multi-element filter list, while a single param yields a one-element
/// list. Unknown enum names / un-parseable Guids / dates are silently dropped
/// (no 400) — the same lax contract the CentralUI export endpoint uses; an
/// unparseable value within a repeated set is dropped, not the whole set.
/// </summary>
/// <remarks>
/// This endpoint reads the source-site filter from the <c>sourceSiteId</c>
/// query key, whereas the CentralUI export endpoint reads it as <c>site</c>.
/// The divergence is deliberate — each endpoint matches its own CLI / UI URL
/// builder — so do NOT "fix" the two to a single key name.
/// </remarks>
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
AuditKind? kind = null;
if (query.TryGetValue("kind", out var kindValues)
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
{
kind = parsedKind;
}
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]);
Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues)
@@ -402,10 +396,10 @@ public static class AuditEndpoints
}
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: TrimToNullable(query, "sourceSiteId"),
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sourceSiteIds,
Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId,
@@ -32,6 +32,16 @@ public class SiteCallAuditOptions
/// reports a <c>SiteUnreachable</c> outcome. Default 10 seconds: long enough
/// to absorb a healthy cross-cluster round-trip, short enough that an
/// operator clicking Retry on an offline site gets a fast, honest answer.
/// <para>
/// <b>Ordering invariant:</b> <c>RelayTimeout</c> must stay below
/// <c>CommunicationOptions.QueryTimeout</c> (default 30s), the timeout the
/// outer <c>CommunicationService.RetrySiteCallAsync</c>/<c>DiscardSiteCallAsync</c>
/// Ask of the <c>SiteCallAuditActor</c> uses. The outer Ask must outlive this
/// inner site relay Ask so the inner relay times out first and yields the
/// distinct <c>SiteUnreachable</c> outcome; if the outer Ask expired first,
/// that outcome would be lost to a generic Ask-timeout exception. The
/// defaults (10s &lt; 30s) satisfy this — keep the gap when tuning either.
/// </para>
/// </summary>
public TimeSpan RelayTimeout { get; set; } = TimeSpan.FromSeconds(10);
}
@@ -214,7 +214,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
@@ -282,7 +282,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
@@ -241,7 +241,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50));
// 1 Submit + 1 Attempted = 2 rows so far.
Assert.Equal(2, rows.Count);
@@ -257,7 +257,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50));
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
Assert.InRange(rows.Count, 3, 4);
@@ -160,7 +160,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId);
@@ -207,7 +207,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId);
@@ -260,7 +260,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(sharedId, rows[0].EventId);
@@ -63,8 +63,8 @@ public class AuditExportCommandTests
Until = "2026-05-20T12:00:00Z",
Format = "jsonl",
Output = "/tmp/x",
Channel = "Notification",
Site = "site-9",
Channel = new[] { "Notification" },
Site = new[] { "site-9" },
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
@@ -76,6 +76,90 @@ public class AuditExportCommandTests
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
}
[Fact]
public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditExportArgs
{
Since = "1h",
Until = "2026-05-20T12:00:00Z",
Format = "csv",
Output = "/tmp/x",
Channel = new[] { "ApiOutbound", "DbOutbound" },
Kind = new[] { "ApiCall", "DbWrite" },
Status = new[] { "Failed", "Parked" },
Site = new[] { "site-1", "site-2" },
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
Assert.Equal(new[] { "ApiCall", "DbWrite" }, parsed.GetValues("kind"));
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
}
[Fact]
public void BuildQueryString_OmitsUnsetMultiValueFilters()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditExportArgs
{
Since = "1h",
Until = "0h",
Format = "csv",
Output = "/tmp/x",
};
var qs = AuditExportHelpers.BuildQueryString(args, now);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Null(parsed["channel"]);
Assert.Null(parsed["kind"]);
Assert.Null(parsed["status"]);
Assert.Null(parsed["sourceSiteId"]);
}
[Fact]
public void Export_MultipleChannelValues_SingleToken_AreAccepted()
{
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "DbOutbound",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "--channel", "Notification",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Export_MultiValueChannel_WithOneInvalidName_FailsFast()
{
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(
root, "audit", "export", "--since", "1h", "--until", "0h",
"--format", "csv", "--output", "/tmp/out.csv",
"--channel", "ApiOutbound", "OutboundApi");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
// ---- Streaming export to file -----------------------------------------
private sealed class BodyHandler : HttpMessageHandler
@@ -58,10 +58,10 @@ public class AuditQueryCommandTests
{
Since = "1h",
Until = "2026-05-20T12:00:00Z",
Channel = "ApiOutbound",
Kind = "ApiCallCached",
Status = "Delivered",
Site = "site-1",
Channel = new[] { "ApiOutbound" },
Kind = new[] { "ApiCallCached" },
Status = new[] { "Delivered" },
Site = new[] { "site-1" },
Target = "weather-api",
Actor = "multi-role",
CorrelationId = "abc-123",
@@ -76,7 +76,7 @@ public class AuditQueryCommandTests
Assert.Equal("ApiCallCached", parsed["kind"]);
Assert.Equal("Delivered", parsed["status"]);
Assert.Equal("site-1", parsed["sourceSiteId"]);
// --instance was dropped: AuditLogQueryFilter has no instance column.
// The CLI audit query has no --instance flag, so no instance param is emitted.
Assert.Null(parsed["instance"]);
Assert.Equal("weather-api", parsed["target"]);
Assert.Equal("multi-role", parsed["actor"]);
@@ -96,6 +96,43 @@ public class AuditQueryCommandTests
Assert.Equal("Failed", parsed["status"]);
}
[Fact]
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs
{
Channel = new[] { "ApiOutbound", "DbOutbound" },
Status = new[] { "Failed", "Parked" },
Site = new[] { "site-1", "site-2" },
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
}
[Fact]
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
{
// --errors-only stays a single-status override: it pins status=Failed and
// supersedes any explicit (multi-value) --status selection.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs
{
ErrorsOnly = true,
Status = new[] { "Delivered", "Parked" },
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
}
[Fact]
public void BuildQueryString_Cursor_AppendsAfterParameters()
{
@@ -254,6 +291,38 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
{
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "DbOutbound" });
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
{
// --channel A --channel B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--channel", "ApiOutbound", "--channel", "Notification",
});
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_MultiValueChannel_WithOneInvalidName_FailsFast()
{
// AcceptOnlyFromAmong validates EACH value of the multi-value option.
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(
root, "audit", "query", "--channel", "ApiOutbound", "OutboundApi");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
[Fact]
public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit()
{
@@ -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);
}
}
}
@@ -51,6 +51,40 @@ public class SiteCallsPageTests
_fixture = fixture;
}
/// <summary>
/// Sets the Target-keyword search box and commits the value to the server
/// as its own discrete circuit message before the caller clicks Query.
/// <para>
/// The <c>#sc-search</c> input is a Blazor <c>@bind</c>
/// (commit-on-<c>change</c>): <see cref="ILocator.FillAsync"/> only fires
/// <c>input</c> events, and the <c>change</c> that actually updates
/// <c>_targetFilter</c> on the server fires on blur. The original test
/// relied on the Query <c>ClickAsync</c> itself to blur the field — that
/// makes the <c>change</c> (blur) and the <c>click</c> a single, near-
/// simultaneous gesture and races them over the SignalR circuit: when the
/// <c>click</c> is processed before the <c>change</c> has updated
/// <c>_targetFilter</c>, <c>Search()</c> runs with a stale (empty) keyword
/// and the grid returns unfiltered rows.
/// </para>
/// <para>
/// <see cref="ILocator.DispatchEventAsync"/> raises the <c>change</c> as a
/// fully-awaited action of its own, so its circuit message is enqueued and
/// sent before the later Query <c>ClickAsync</c>'s message. The SignalR
/// connection delivers messages in send order and the Blazor circuit
/// processes them sequentially, so <c>_targetFilter</c> is guaranteed
/// committed before <c>Search()</c> runs — the two are no longer one
/// racing gesture.
/// </para>
/// </summary>
private static async Task SetSearchKeywordAsync(IPage page, string keyword)
{
var search = page.Locator("#sc-search");
await search.FillAsync(keyword);
// Commit the @bind as a discrete change event — not a blur side effect
// of the subsequent Query click.
await search.DispatchEventAsync("change");
}
[Fact]
public async Task PageLoads_ForDeploymentUser()
{
@@ -98,22 +132,26 @@ public class SiteCallsPageTests
// Unfiltered query: both seeded rows appear (the Target keyword scopes
// to this run so unrelated cluster rows do not interfere).
await page.Locator("#sc-search").FillAsync(targetPrefix + "api");
await SetSearchKeywordAsync(page, targetPrefix + "api");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Only the ApiOutbound row matches the exact target keyword.
// Only the ApiOutbound row matches the exact target keyword. The
// grid filters with an exact Target match, so the db row must be
// absent — use the retrying ToHaveCount assertion so the negative
// check waits out the post-query re-render rather than reading a
// point-in-time count.
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync();
Assert.Equal(0, await page.Locator($"text={targetPrefix}db").CountAsync());
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToHaveCountAsync(0);
// Now filter by Channel = DbOutbound with the db target — the row flips.
await page.Locator("#sc-search").FillAsync(targetPrefix + "db");
await SetSearchKeywordAsync(page, targetPrefix + "db");
await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync();
Assert.Equal(0, await page.Locator($"text={targetPrefix}api").CountAsync());
await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToHaveCountAsync(0);
}
finally
{
@@ -142,7 +180,7 @@ public class SiteCallsPageTests
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("#sc-search").FillAsync(targetPrefix + "endpoint");
await SetSearchKeywordAsync(page, targetPrefix + "endpoint");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
@@ -199,7 +237,7 @@ public class SiteCallsPageTests
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Query the parked row first.
await page.Locator("#sc-search").FillAsync(targetPrefix + "parked");
await SetSearchKeywordAsync(page, targetPrefix + "parked");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
@@ -210,7 +248,7 @@ public class SiteCallsPageTests
await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();
// Now the Failed row — Retry/Discard are absent.
await page.Locator("#sc-search").FillAsync(targetPrefix + "failed");
await SetSearchKeywordAsync(page, targetPrefix + "failed");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
@@ -238,10 +276,18 @@ public class SiteCallsPageTests
try
{
// A single Parked row — the only status from which Retry/Discard can
// be relayed to the owning site.
// be relayed to the owning site. Unlike the display-only tests above,
// this one actually relays to the owning site, so the SourceSite must
// be a *real* site identifier from the running cluster (site-a) and
// not the cosmetic "plant-a" label: an unknown site has no registered
// ClusterClient, so CentralCommunicationActor drops the envelope
// without replying and the relay only resolves on the 10s inner Ask
// timeout — too slow for the toast assertion below. Relayed to a live
// site, the site finds no parked S&F message for this freshly-seeded
// GUID and replies a fast NotParked ack, which still surfaces a toast.
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
sourceSite: "plant-a", status: "Parked", retryCount: 3,
sourceSite: "site-a", status: "Parked", retryCount: 3,
lastError: "HTTP 503 from ERP", httpStatus: 503,
createdAtUtc: now, updatedAtUtc: now);
@@ -249,7 +295,7 @@ public class SiteCallsPageTests
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("#sc-search").FillAsync(targetPrefix + "parked");
await SetSearchKeywordAsync(page, targetPrefix + "parked");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
@@ -269,9 +315,14 @@ public class SiteCallsPageTests
// the owning site is offline in this environment, SiteUnreachable.
// We only assert that an outcome toast appears (exactly one — the
// single-toast contract), not which one, since the live cluster
// state determines the outcome.
// state determines the outcome. The wait is generous (15s): the
// relay round-trips to the site over ClusterClient, and a worst-case
// path can sit on the 10s inner relay timeout before the response —
// and the toast itself auto-dismisses 5s after it appears, so the
// assertion must catch it inside that window.
var toast = page.Locator(".toast");
await Assertions.Expect(toast).ToBeVisibleAsync();
await Assertions.Expect(toast).ToBeVisibleAsync(
new() { Timeout = 15_000 });
Assert.Equal(1, await toast.CountAsync());
}
finally
@@ -160,10 +160,10 @@ public class AuditExportEndpointsTests
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == AuditChannel.ApiOutbound &&
f.Kind == AuditKind.ApiCall &&
f.Status == AuditStatus.Failed &&
f.SourceSiteId == "plant-a" &&
f.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound &&
f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall &&
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed &&
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" &&
f.Target == "PaymentApi" &&
f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) &&
@@ -188,10 +188,10 @@ public class AuditExportEndpointsTests
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == null &&
f.Kind == null &&
f.Status == null &&
f.SourceSiteId == null &&
f.Channels == null &&
f.Kinds == null &&
f.Statuses == null &&
f.SourceSiteIds == null &&
f.Target == null &&
f.Actor == null &&
f.CorrelationId == null &&
@@ -216,7 +216,7 @@ public class AuditExportEndpointsTests
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Channel == null),
Arg.Is<AuditLogQueryFilter>(f => f.Channels == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
@@ -77,10 +77,30 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel);
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
Assert.Equal("Plant-A-OPC", captured.Target);
}
[Fact]
public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels()
{
// Task 9: ToFilter no longer collapses the chip multi-select — every
// selected channel chip reaches the filter's Channels list.
AuditLogQueryFilter? captured = null;
var cut = Render<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]
public void Channel_Narrows_Kind_Options_When_Selected()
{
@@ -117,14 +137,38 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
// Single-value filter contract: Failed leads the non-success set.
Assert.Equal(AuditStatus.Failed, captured!.Status);
// Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}.
Assert.NotNull(captured!.Statuses);
Assert.Equal(3, captured.Statuses!.Count);
Assert.Contains(AuditStatus.Failed, captured.Statuses);
Assert.Contains(AuditStatus.Parked, captured.Statuses);
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(AuditStatus.Delivered, captured!.Status);
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
[Fact]
public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
{
// Task 9: multiple explicit Status chips all reach the filter — and they
// win over the Errors-only default.
AuditLogQueryFilter? captured = null;
var cut = Render<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]
@@ -43,6 +43,12 @@ public class AuditResultsGridTests : BunitContext
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
// The grid's OnAfterRenderAsync calls into audit-grid.js (init + the
// sessionStorage load). Loose mode lets those unconfigured calls no-op
// — auditGrid.load returns null (no prior state) unless a test sets up
// an explicit JSInterop.Setup to return a stored payload.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private void StubPage(IReadOnlyList<AuditEvent> rows)
@@ -131,4 +137,133 @@ public class AuditResultsGridTests : BunitContext
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
}
// --- column resize + reorder UX (#23 follow-ups Task 10) ---------------
//
// The drag interaction itself is browser-side (audit-grid.js) and covered
// by the Playwright suite. The bUnit tests below exercise the .NET-side
// load/apply/persist logic that the JS callbacks drive: graceful handling
// of stored orders, the reorder slot-move maths, and the resize minimum.
/// <summary>Column keys in default (spec) order — the fallback used everywhere.</summary>
private static readonly string[] DefaultOrder =
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
};
private static int HeaderIndex(string markup, string key)
=> markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal);
[Fact]
public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
foreach (var key in DefaultOrder)
{
// Each <th> carries the stable drag key and a resize handle.
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup);
}
}
[Fact]
public void ColumnOrderParameter_DrivesHeaderOrder()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.ColumnOrder, new[] { "Status", "Site" }));
// Status + Site move to the front; the omitted columns still render,
// appended in default order — Status precedes Site precedes Channel.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel"));
// No column is dropped — all ten headers are present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Drag Status onto OccurredAtUtc — Status should land in slot 0.
await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc"));
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc"));
// The new order was persisted to sessionStorage under the order key.
// Loose-mode JSInterop records every InvokeVoidAsync; find the save call.
var save = JSInterop.Invocations
.Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder");
Assert.Contains("Status", (string)save.Arguments[1]!);
}
[Fact]
public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A drag that would shrink the column to 10px must clamp to the 64px floor.
await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10));
// The clamped width is reflected as the --audit-col-width custom property.
Assert.Contains("--audit-col-width: 64px", cut.Markup);
// The width was persisted to sessionStorage under the widths key.
Assert.Contains(JSInterop.Invocations,
i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths");
}
[Fact]
public void StoredOrder_WithUnknownKey_DegradesGracefully()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
// A stale persisted order naming a removed column ("LegacyCol") plus a
// subset of real columns — the unknown key must be dropped and the
// omitted real columns appended in default order, never throwing.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult("[\"Status\",\"LegacyCol\",\"Site\"]");
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult((string?)null);
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Restored order applied: Status then Site at the front.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
// The unknown key produced no header and did not break rendering.
Assert.DoesNotContain("LegacyCol", cut.Markup);
// All ten real columns still present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public void StoredWidths_ForUnknownColumn_AreIgnored()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult((string?)null);
// A width for a real column and one for a removed column.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult("{\"Target\":220,\"LegacyCol\":300}");
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The valid column's width was applied; the stale one silently ignored.
Assert.Contains("--audit-col-width: 220px", cut.Markup);
Assert.DoesNotContain("300px", cut.Markup);
}
}
@@ -0,0 +1,177 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Components.Health;
using ScadaLink.Commons.Messages.Audit;
namespace ScadaLink.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="SiteCallKpiTiles"/> (Site Call Audit #22, Task 7).
/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked —
/// from a single <see cref="SiteCallKpiResponse"/> snapshot. The tests pin:
///
/// <list type="bullet">
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
/// <item>Tile values render the snapshot's counters.</item>
/// <item>Threshold borders fire correctly — danger on Parked &gt; 0, warning
/// on Stuck &gt; 0, none when those counts are zero, none on Buffered.</item>
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
/// <item>Tile clicks navigate to the correct pre-filtered Site Calls report URL.</item>
/// </list>
/// </summary>
public class SiteCallKpiTilesTests : BunitContext
{
private static SiteCallKpiResponse MakeSnapshot(int buffered, int parked, int stuck) =>
new(
CorrelationId: "k",
Success: true,
ErrorMessage: null,
BufferedCount: buffered,
ParkedCount: parked,
FailedLastInterval: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null,
StuckCount: stuck);
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 120, parked: 3, stuck: 7))
.Add(c => c.IsAvailable, true));
// Three stable data-test selectors — the contract for both these tests
// and any future Playwright sweep.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// Tile values render the snapshot's counters.
Assert.Contains(">120<", cut.Markup); // buffered
Assert.Contains(">7<", cut.Markup); // stuck
Assert.Contains(">3<", cut.Markup); // parked
}
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, (SiteCallKpiResponse?)null)
.Add(c => c.IsAvailable, false)
.Add(c => c.ErrorMessage, "site call repository unavailable"));
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
Assert.Contains("—", cut.Markup);
// Inline error message renders below.
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
}
[Fact]
public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void ParkedTile_NoDangerBorder_WhenParkedZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_GetsWarningBorder_WhenStuckAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
// Warning, not danger — Stuck is the softer signal.
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_NoWarningBorder_WhenStuckZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount()
{
// A non-zero buffer is normal operation — the Buffered tile is a plain
// count tile and never gets a danger/warning border.
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 5000, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
var cls = tile.GetAttribute("class") ?? string.Empty;
Assert.DoesNotContain("border-danger", cls);
Assert.DoesNotContain("border-warning", cls);
}
[Fact]
public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 50, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
tile.Click();
// Unfiltered /site-calls/report — no query string.
Assert.EndsWith("/site-calls/report", nav.Uri);
}
[Fact]
public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
tile.Click();
// Spec: Stuck tile drills into the report's "stuck only" filter.
Assert.Contains("/site-calls/report?stuck=true", nav.Uri);
}
[Fact]
public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
tile.Click();
// Spec: Parked tile drills into ?status=Parked.
Assert.Contains("/site-calls/report?status=Parked", nav.Uri);
}
}
@@ -36,10 +36,10 @@ public class AuditLogPageExportUrlTests
{
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var filter = new AuditLogQueryFilter(
Channel: AuditChannel.ApiOutbound,
Kind: AuditKind.ApiCall,
Status: AuditStatus.Failed,
SourceSiteId: "plant-a",
Channels: new[] { AuditChannel.ApiOutbound },
Kinds: new[] { AuditKind.ApiCall },
Statuses: new[] { AuditStatus.Failed },
SourceSiteIds: new[] { "plant-a" },
Target: "PaymentApi",
Actor: "apikey-1",
CorrelationId: corr,
@@ -65,7 +65,7 @@ public class AuditLogPageExportUrlTests
[Fact]
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
{
var filter = new AuditLogQueryFilter(Channel: AuditChannel.Notification);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.Notification });
var url = AuditLogPage.BuildExportUrl(filter);
@@ -74,4 +74,22 @@ public class AuditLogPageExportUrlTests
Assert.Single(query);
Assert.Equal("Notification", query["channel"]);
}
[Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{
// Task 9: each multi-value dimension emits one repeated query-string key
// per selected value so the export endpoint's ParseFilter sees them all.
var filter = new AuditLogQueryFilter(
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound },
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
SourceSiteIds: new[] { "plant-a", "plant-b" });
var url = AuditLogPage.BuildExportUrl(filter);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, query["channel"].ToArray());
Assert.Equal(new[] { "Failed", "Parked" }, query["status"].ToArray());
Assert.Equal(new[] { "plant-a", "plant-b" }, query["site"].ToArray());
}
}
@@ -46,6 +46,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPagePermissionTests : BunitContext
{
public AuditLogPagePermissionTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
// an init call). Loose mode lets those unconfigured JS calls no-op so
// the permission-gating tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
@@ -28,6 +28,15 @@ namespace ScadaLink.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPageScaffoldTests : BunitContext
{
public AuditLogPageScaffoldTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
// column resize/reorder UX via audit-grid.js (a sessionStorage load +
// an init call). Loose mode lets those unconfigured JS calls no-op so
// the page scaffold smoke tests need not configure browser interop.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
@@ -197,7 +206,8 @@ public class AuditLogPageScaffoldTests : BunitContext
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.SourceSiteId == "plant-a"),
Arg.Is<AuditLogQueryFilter>(f =>
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
@@ -218,7 +228,8 @@ public class AuditLogPageScaffoldTests : BunitContext
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Status == AuditStatus.Failed),
Arg.Is<AuditLogQueryFilter>(f =>
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
@@ -9,6 +9,7 @@ using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Messages.Audit;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types;
using ScadaLink.Communication;
@@ -37,6 +38,13 @@ public class HealthPageTests : BunitContext
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
// Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests
// that target the Site Call tiles override this before rendering.
private SiteCallKpiResponse _siteCallKpiReply =
new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1,
DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3),
StuckCount: 5);
public HealthPageTests()
{
_comms = new CommunicationService(
@@ -45,6 +53,9 @@ public class HealthPageTests : BunitContext
var outbox = _system.ActorOf(Props.Create(() => new ScriptedOutboxActor(this)));
_comms.SetNotificationOutbox(outbox);
var siteCallAudit = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
_comms.SetSiteCallAudit(siteCallAudit);
Services.AddSingleton(_comms);
var aggregator = Substitute.For<ICentralHealthAggregator>();
@@ -133,6 +144,53 @@ public class HealthPageTests : BunitContext
});
}
[Fact]
public void Renders_SiteCallKpiTiles_WithValues()
{
var cut = Render<HealthPage>();
// KPI data arrives via an async actor Ask after first render.
cut.WaitForAssertion(() =>
{
Assert.Contains("Site Calls", cut.Markup);
// The three Site Call tiles render at the documented data-test selectors.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// KPI numeric values surface in the tiles.
Assert.Contains(">9<", cut.Markup); // BufferedCount
Assert.Contains(">5<", cut.Markup); // StuckCount
Assert.Contains(">2<", cut.Markup); // ParkedCount
});
}
[Fact]
public void RendersLinkToTheSiteCallsReportPage()
{
var cut = Render<HealthPage>();
var link = cut.Find("a[href='/site-calls/report']");
Assert.Contains("View details", link.TextContent);
}
[Fact]
public void SiteCallKpiFailure_ShowsGracefulFallback()
{
_siteCallKpiReply = new SiteCallKpiResponse(
"k", false, "site call repository unavailable", 0, 0, 0, 0, null, 0);
var cut = Render<HealthPage>();
cut.WaitForAssertion(() =>
{
// Failure must not crash the page; tiles fall back to a dash and the
// inline error message surfaces.
Assert.Contains("Site Calls", cut.Markup);
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
Assert.Contains(">—<", cut.Markup);
});
}
[Fact]
public void OutboxKpiFailure_ShowsGracefulFallback()
{
@@ -170,4 +228,16 @@ public class HealthPageTests : BunitContext
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
}
}
/// <summary>
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
/// the test's currently-scripted response.
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
public ScriptedSiteCallAuditActor(HealthPageTests test)
{
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
}
}
}
@@ -1,7 +1,9 @@
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -411,6 +413,77 @@ public class SiteCallsReportPageTests : BunitContext
});
}
// ─────────────────────────────────────────────────────────────────────────
// Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link
// here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The
// params must seed the filter BEFORE the first query so the initial grid load
// is already filtered, and the filter card controls must reflect the values.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
{
// The Parked KPI tile emits ?status=Parked — set the URI before render.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?status=Parked");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first (and only) query the page issues carries the Parked
// status filter — the grid load is pre-filtered, not unfiltered.
Assert.Single(_queryRequests);
Assert.Equal("Parked", _queryRequests[0].StatusFilter);
// The Status <select> control reflects the seeded value so the
// operator sees the filter and can Clear it.
var statusSelect = cut.Find("#sc-status");
Assert.Equal("Parked", statusSelect.GetAttribute("value"));
});
}
[Fact]
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
{
// The Stuck KPI tile emits ?stuck=true.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo("/site-calls/report?stuck=true");
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
// The first query carries StuckOnly = true.
Assert.Single(_queryRequests);
Assert.True(_queryRequests[0].StuckOnly);
// The "Stuck only" checkbox is checked.
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.True(stuckCheckbox.HasAttribute("checked"));
});
}
[Fact]
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
{
// No drill-in params — the page loads exactly as before: an unfiltered
// query and no status/stuck filter set on the controls.
var cut = Render<SiteCallsReportPage>();
cut.WaitForAssertion(() =>
{
Assert.Single(_queryRequests);
Assert.Null(_queryRequests[0].StatusFilter);
Assert.False(_queryRequests[0].StuckOnly);
var statusSelect = cut.Find("#sc-status");
Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
var stuckCheckbox = cut.Find("#sc-stuck-only");
Assert.False(stuckCheckbox.HasAttribute("checked"));
});
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -34,7 +34,7 @@ public class AuditLogQueryServiceTests
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
{
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
var paging = new AuditLogPaging(PageSize: 25);
var expected = new List<AuditEvent>
{
@@ -179,7 +179,7 @@ public class AuditLogQueryServiceTests
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
// fresh DbContext, so this completes cleanly; with a shared scoped context
@@ -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 }));
}
}
@@ -91,7 +91,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(3, rows.Count);
@@ -114,13 +114,116 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId),
new AuditLogQueryFilter(
Channels: new[] { AuditChannel.Notification },
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
}
[SkippableFact]
public async Task QueryAsync_FilterByMultipleChannels_ReturnsUnion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 14, 0, 0, DateTimeKind.Utc);
// One row per channel; the multi-value filter must return the union of
// ApiOutbound + Notification and exclude DbOutbound.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.DbOutbound));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.Notification },
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
Assert.DoesNotContain(rows, r => r.Channel == AuditChannel.DbOutbound);
}
[SkippableFact]
public async Task QueryAsync_FilterByMultipleStatuses_ReturnsUnion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 15, 0, 0, DateTimeKind.Utc);
// Failed + Parked are requested; Delivered must be excluded.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, status: AuditStatus.Failed));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), status: AuditStatus.Parked));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), status: AuditStatus.Delivered));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
Assert.DoesNotContain(rows, r => r.Status == AuditStatus.Delivered);
}
[SkippableFact]
public async Task QueryAsync_FilterByMultipleSourceSiteIds_ReturnsUnion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteA = NewSiteId();
var siteB = NewSiteId();
var siteC = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 16, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteA, occurredAtUtc: t0));
await repo.InsertIfNotExistsAsync(NewEvent(siteB, occurredAtUtc: t0.AddMinutes(1)));
await repo.InsertIfNotExistsAsync(NewEvent(siteC, occurredAtUtc: t0.AddMinutes(2)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteA, siteB }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.SourceSiteId, new[] { siteA, siteB }));
Assert.DoesNotContain(rows, r => r.SourceSiteId == siteC);
}
[SkippableFact]
public async Task QueryAsync_EmptyChannelList_DoesNotConstrain()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 2, 17, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
// An empty Channels list must mean "no filter" — NOT WHERE 1=0.
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
Channels: Array.Empty<AuditChannel>(),
SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
}
[SkippableFact]
public async Task QueryAsync_FilterBySourceSiteId()
{
@@ -137,7 +240,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
@@ -160,7 +263,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteId: siteId,
SourceSiteIds: new[] { siteId },
FromUtc: t0.AddMinutes(10),
ToUtc: t0.AddHours(1)),
new AuditLogPaging(PageSize: 10));
@@ -187,7 +290,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
var page1 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count);
@@ -196,7 +299,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var cursor = page1[^1];
var page2 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor.OccurredAtUtc,
@@ -208,7 +311,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var cursor2 = page2[^1];
var page3 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
@@ -281,7 +384,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(e);
}
var filter = new AuditLogQueryFilter(SourceSiteId: siteId);
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { siteId });
var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count);
@@ -367,6 +367,89 @@ public class AuditEndpointsTests
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]
public void ParsePaging_HalfSuppliedCursor_IsDropped()
{