feat(audit): multi-value filters across ManagementService, CLI and Central UI

This commit is contained in:
Joseph Doherty
2026-05-21 05:27:17 -04:00
parent 37c7a0e5ac
commit 2a76be1f94
11 changed files with 523 additions and 146 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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))
{