using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
///
/// Filter arguments for an audit query invocation. Mirrors the
/// 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
{
/// Start time spec (relative like 1h, or absolute ISO-8601).
public string? Since { get; set; }
/// End time spec (relative like 7d, or absolute ISO-8601).
public string? Until { get; set; }
/// Multi-valued channel filter.
public string[] Channel { get; set; } = Array.Empty();
/// Multi-valued audit event kind filter.
public string[] Kind { get; set; } = Array.Empty();
/// Multi-valued status filter.
public string[] Status { get; set; } = Array.Empty();
/// Multi-valued site ID filter.
public string[] Site { get; set; } = Array.Empty();
/// Target system or service filter.
public string? Target { get; set; }
/// Actor (user or system) filter.
public string? Actor { get; set; }
/// Operation correlation ID filter.
public string? CorrelationId { get; set; }
/// Script execution ID filter.
public string? ExecutionId { get; set; }
/// Parent execution ID filter.
public string? ParentExecutionId { get; set; }
/// Filter for errors only (status=Failed).
public bool ErrorsOnly { get; set; }
/// Page size for pagination.
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 time specification string.
/// The current time used as reference for relative specs.
/// The spec is neither a known relative form nor a parseable ISO-8601 timestamp.
/// The resolved absolute in UTC.
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.
///
/// The audit query arguments.
/// The current time for resolving relative time specs.
/// Optional keyset cursor timestamp.
/// Optional keyset cursor event ID.
/// A URL query string (starting with ?) containing the encoded filter parameters, or an empty string if no parameters are set.
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("parentExecutionId", args.ParentExecutionId);
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).
///
/// The management HTTP client.
/// The audit query arguments.
/// Whether to follow pagination cursors.
/// The audit result formatter.
/// The output writer for results.
/// The current time for resolving relative time specs.
/// A task that resolves to 0 on success, 1 on HTTP/transport error, or 2 on authorization failure.
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");
// Surface the documented "authorization failure → exit 2"
// contract for the audit REST surface too, not just /management.
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 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;
}
}
}