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

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