feat(audit): multi-value filters across ManagementService, CLI and Central UI
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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&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);
|
||||
|
||||
@@ -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,21 @@ 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>
|
||||
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 = ParseEnumList<AuditChannel>(query, "channel");
|
||||
var kinds = ParseEnumList<AuditKind>(query, "kind");
|
||||
var statuses = ParseEnumList<AuditStatus>(query, "status");
|
||||
var sites = 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 +103,10 @@ public static class AuditExportEndpoints
|
||||
DateTime? toUtc = ParseUtcDate(query, "to");
|
||||
|
||||
return new AuditLogQueryFilter(
|
||||
Channels: channel is { } c ? new[] { c } : null,
|
||||
Kinds: kind is { } k ? new[] { k } : null,
|
||||
Statuses: status is { } s ? new[] { s } : null,
|
||||
SourceSiteIds: site is { } siteId ? new[] { siteId } : null,
|
||||
Channels: channels,
|
||||
Kinds: kinds,
|
||||
Statuses: statuses,
|
||||
SourceSiteIds: sites,
|
||||
Target: target,
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId,
|
||||
@@ -127,6 +114,53 @@ public static class AuditExportEndpoints
|
||||
ToUtc: toUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads EVERY value of a (possibly repeated) query param and parses each as
|
||||
/// <typeparamref name="TEnum"/>, dropping unparseable values silently. Returns
|
||||
/// <c>null</c> when the param is absent or no value parsed.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IQueryCollection query, string key)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<TEnum>();
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
||||
{
|
||||
parsed.Add(value);
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads EVERY value of a (possibly repeated) query param, trims each, and
|
||||
/// drops blank entries. Returns <c>null</c> when the param is absent or every
|
||||
/// value was blank.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string>? ParseStringList(IQueryCollection query, string key)
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<string>();
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
parsed.Add(raw.Trim());
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional <c>maxRows=</c> query-string override. Falls back to
|
||||
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable
|
||||
|
||||
@@ -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(
|
||||
Channels: Channels.Count > 0 ? new[] { Channels.First() } : null,
|
||||
Kinds: Kinds.Count > 0 ? new[] { Kinds.First() } : null,
|
||||
Statuses: status is { } s ? new[] { s } : null,
|
||||
SourceSiteIds: SiteIdentifiers.Count > 0 ? new[] { 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;
|
||||
|
||||
@@ -80,33 +80,18 @@ 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/status accept repeated params for symmetry with the
|
||||
// multi-value export URL — a single ?site=/?channel=/?status= drill-in
|
||||
// still works (one-element list). Unknown enum names are silently dropped.
|
||||
IReadOnlyList<string>? sites = ParseStringList(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 = ParseEnumList<AuditChannel>(query, "channel");
|
||||
|
||||
// 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 = ParseEnumList<AuditStatus>(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 +108,68 @@ 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 && statuses is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentFilter = new AuditLogQueryFilter(
|
||||
Channels: channel is { } ch ? new[] { ch } : null,
|
||||
Statuses: status is { } st ? new[] { st } : null,
|
||||
SourceSiteIds: site is { } siteId ? new[] { siteId } : null,
|
||||
Channels: channels,
|
||||
Statuses: statuses,
|
||||
SourceSiteIds: sites,
|
||||
Target: target,
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads EVERY value of a (possibly repeated) query param and parses each as
|
||||
/// <typeparamref name="TEnum"/>, dropping unparseable values silently. Returns
|
||||
/// <c>null</c> when the param is absent or no value parsed.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(
|
||||
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<TEnum>();
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
||||
{
|
||||
parsed.Add(value);
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads EVERY value of a (possibly repeated) query param, trims each, and
|
||||
/// drops blank entries. Returns <c>null</c> when absent or all-blank.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string>? ParseStringList(
|
||||
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<string>();
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
parsed.Add(raw.Trim());
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
private void HandleFilterChanged(AuditLogQueryFilter filter)
|
||||
{
|
||||
// Always reassign — the grid keys reloads on reference change, so even a
|
||||
@@ -181,24 +214,39 @@ public partial class AuditLogPage
|
||||
}
|
||||
|
||||
var parts = new List<KeyValuePair<string, string?>>(9);
|
||||
// Task 8: the filter dimensions are multi-value now; the export query-string
|
||||
// is still single-value, so emit the first selected value of each list.
|
||||
// Task 9 widens the query-string contract to carry the full set.
|
||||
// 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", channels[0].ToString()));
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
parts.Add(new("channel", channel.ToString()));
|
||||
}
|
||||
}
|
||||
if (filter.Kinds is { Count: > 0 } kinds)
|
||||
{
|
||||
parts.Add(new("kind", kinds[0].ToString()));
|
||||
foreach (var kind in kinds)
|
||||
{
|
||||
parts.Add(new("kind", kind.ToString()));
|
||||
}
|
||||
}
|
||||
if (filter.Statuses is { Count: > 0 } statuses)
|
||||
{
|
||||
parts.Add(new("status", statuses[0].ToString()));
|
||||
foreach (var status in statuses)
|
||||
{
|
||||
parts.Add(new("status", status.ToString()));
|
||||
}
|
||||
}
|
||||
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds && !string.IsNullOrWhiteSpace(sourceSiteIds[0]))
|
||||
if (filter.SourceSiteIds is { Count: > 0 } sourceSiteIds)
|
||||
{
|
||||
parts.Add(new("site", sourceSiteIds[0]));
|
||||
foreach (var site in sourceSiteIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(site))
|
||||
{
|
||||
parts.Add(new("site", site));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(filter.Target))
|
||||
{
|
||||
|
||||
@@ -367,32 +367,20 @@ public static class AuditEndpoints
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. Unknown
|
||||
/// enum names / un-parseable Guids / dates are silently dropped (no 400) —
|
||||
/// the same lax contract the CentralUI export endpoint uses.
|
||||
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
|
||||
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
|
||||
/// multi-value: a repeated query param (<c>channel=A&channel=B</c>) yields
|
||||
/// a multi-element filter list, while a single param yields a one-element
|
||||
/// list. Unknown enum names / un-parseable Guids / dates are silently dropped
|
||||
/// (no 400) — the same lax contract the CentralUI export endpoint uses; an
|
||||
/// unparseable value within a repeated set is dropped, not the whole set.
|
||||
/// </summary>
|
||||
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 = ParseEnumList<AuditChannel>(query, "channel");
|
||||
var kinds = ParseEnumList<AuditKind>(query, "kind");
|
||||
var statuses = ParseEnumList<AuditStatus>(query, "status");
|
||||
var sourceSiteIds = ParseStringList(query, "sourceSiteId");
|
||||
|
||||
Guid? correlationId = null;
|
||||
if (query.TryGetValue("correlationId", out var corrValues)
|
||||
@@ -401,13 +389,11 @@ public static class AuditEndpoints
|
||||
correlationId = parsedCorr;
|
||||
}
|
||||
|
||||
var sourceSiteId = TrimToNullable(query, "sourceSiteId");
|
||||
|
||||
return new AuditLogQueryFilter(
|
||||
Channels: channel is { } c ? new[] { c } : null,
|
||||
Kinds: kind is { } k ? new[] { k } : null,
|
||||
Statuses: status is { } s ? new[] { s } : null,
|
||||
SourceSiteIds: sourceSiteId is { } site ? new[] { site } : null,
|
||||
Channels: channels,
|
||||
Kinds: kinds,
|
||||
Statuses: statuses,
|
||||
SourceSiteIds: sourceSiteIds,
|
||||
Target: TrimToNullable(query, "target"),
|
||||
Actor: TrimToNullable(query, "actor"),
|
||||
CorrelationId: correlationId,
|
||||
@@ -415,6 +401,54 @@ public static class AuditEndpoints
|
||||
ToUtc: ParseUtcDate(query, "toUtc"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads EVERY value of a (possibly repeated) query param and parses each as
|
||||
/// <typeparamref name="TEnum"/>, dropping unparseable values silently. Returns
|
||||
/// <c>null</c> when the param is absent or no value parsed — so the filter
|
||||
/// dimension stays unconstrained.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IQueryCollection query, string key)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<TEnum>();
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
||||
{
|
||||
parsed.Add(value);
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads EVERY value of a (possibly repeated) query param, trims each, and
|
||||
/// drops blank entries. Returns <c>null</c> when the param is absent or every
|
||||
/// value was blank.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string>? ParseStringList(IQueryCollection query, string key)
|
||||
{
|
||||
if (!query.TryGetValue(key, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<string>();
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
parsed.Add(raw.Trim());
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the keyset-paging query parameters into an
|
||||
/// <see cref="AuditLogPaging"/>. <c>pageSize</c> is clamped to
|
||||
|
||||
Reference in New Issue
Block a user