using System.Globalization; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace ScadaLink.CLI.Commands; /// /// Filter arguments for an audit query invocation. Mirrors the Bundle B /// GET /api/audit/query filter parameters; / /// are time-specs (relative like 1h/7d, or absolute ISO-8601). /// /// /// are multi-valued — each supplied value becomes a repeated query-string param so /// the server's multi-value IN (…) filter sees the full set. /// public sealed class AuditQueryArgs { public string? Since { get; set; } public string? Until { get; set; } public string[] Channel { get; set; } = Array.Empty(); public string[] Kind { get; set; } = Array.Empty(); public string[] Status { get; set; } = Array.Empty(); public string[] Site { get; set; } = Array.Empty(); public string? Target { get; set; } public string? Actor { get; set; } public string? CorrelationId { get; set; } public string? ExecutionId { get; set; } public bool ErrorsOnly { get; set; } public int PageSize { get; set; } = 100; } /// /// Pure helpers for the audit query subcommand: time-spec resolution, query-string /// construction, and the keyset-cursor paging loop. Kept separate from the command wiring /// so each piece is unit-testable without standing up the command tree. /// public static class AuditQueryHelpers { // where unit is s/m/h/d — a relative offset back from "now". private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled); /// /// Resolves a time-spec to an absolute . Accepts a /// relative offset (30s, 15m, 1h, 7d) interpreted as /// minus the offset, or an absolute ISO-8601 timestamp. /// /// The spec is neither a known relative form nor a parseable ISO-8601 timestamp. public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now) { if (string.IsNullOrWhiteSpace(spec)) throw new FormatException("Empty time value."); var match = RelativeSpec.Match(spec.Trim()); if (match.Success) { var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var offset = match.Groups[2].Value switch { "s" => TimeSpan.FromSeconds(amount), "m" => TimeSpan.FromMinutes(amount), "h" => TimeSpan.FromHours(amount), "d" => TimeSpan.FromDays(amount), _ => throw new FormatException($"Unknown time unit in '{spec}'."), }; return now - offset; } if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute)) { return absolute; } throw new FormatException( $"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp."); } /// /// Builds the ?... query string for GET /api/audit/query from the filter /// args plus an optional keyset cursor. Unset filters are omitted. The multi-valued /// --channel/--kind/--status/--site filters each emit ONE /// repeated query-string key per value (e.g. channel=A&channel=B) so the /// server's multi-value IN (…) filter receives the full set. --errors-only /// maps to a single status=Failed and overrides any explicit --status. /// public static string BuildQueryString( AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) { var parts = new List(); void Add(string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) parts.Add($"{key}={Uri.EscapeDataString(value)}"); } void AddEach(string key, IReadOnlyList 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)); AddEach("channel", args.Channel); AddEach("kind", args.Kind); // --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); } AddEach("sourceSiteId", args.Site); Add("target", args.Target); Add("actor", args.Actor); Add("correlationId", args.CorrelationId); Add("executionId", args.ExecutionId); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); if (afterOccurredAtUtc.HasValue) Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture)); Add("afterEventId", afterEventId); return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts); } /// /// Executes the query: GETs /api/audit/query, renders each page with /// , and — when is set — /// follows nextCursor until the server returns a null cursor. Returns the /// process exit code (0 success, non-zero on HTTP/transport error). /// public static async Task RunQueryAsync( ManagementHttpClient client, AuditQueryArgs args, bool fetchAll, IAuditFormatter formatter, TextWriter output, DateTimeOffset now) { DateTimeOffset? afterOccurredAtUtc = null; string? afterEventId = null; while (true) { var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId); var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30)); if (response.JsonData == null) { OutputFormatter.WriteError( response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR"); return 1; } using var doc = JsonDocument.Parse(response.JsonData); var root = doc.RootElement; var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array ? evts.EnumerateArray().ToList() : new List(); formatter.WritePage(events, output); output.Flush(); if (!fetchAll) return 0; if (!root.TryGetProperty("nextCursor", out var cursor) || cursor.ValueKind != JsonValueKind.Object) { return 0; } afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1) && c1.ValueKind == JsonValueKind.String ? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal) : null; afterEventId = cursor.TryGetProperty("afterEventId", out var c2) && c2.ValueKind == JsonValueKind.String ? c2.GetString() : null; // A malformed cursor (object present but missing both keys) would loop // forever — treat it as the end of results. if (afterOccurredAtUtc == null && afterEventId == null) return 0; } } }