feat(audit): multi-value filters across ManagementService, CLI and Central UI
This commit is contained in:
@@ -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))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user