";
private static bool IsApiChannel(AuditChannel channel)
=> channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound;
private static string ShortEventId(Guid eventId)
{
// Mirror the "first 8 hex digits" presentation common across the UI.
var n = eventId.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private static string FormatTimestamp(DateTime utc)
{
// Force UTC kind in case the row arrived as Unspecified, then emit
// round-trip ISO-8601 so audit drilldowns are copy-paste safe.
var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc;
return kind.ToString("o", CultureInfo.InvariantCulture);
}
private static bool IsRedacted(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
return text.Contains(RedactionSentinel, StringComparison.Ordinal)
|| text.Contains(RedactorErrorSentinel, StringComparison.Ordinal);
}
///
/// Channel-aware body renderer. DbOutbound bodies that parse as
/// {sql, parameters} get a SQL block + parameter list; anything
/// else falls back to JSON-pretty-print, then plain-text verbatim.
///
private RenderFragment RenderBody(string body, AuditChannel channel) => builder =>
{
// DbOutbound special-case: try to extract {sql, parameters}.
if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters))
{
builder.OpenElement(0, "pre");
builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre");
builder.OpenElement(2, "code");
// Highlighting is CSS-class-only — no JS library is loaded.
builder.AddAttribute(3, "class", "language-sql");
builder.AddContent(4, sql);
builder.CloseElement();
builder.CloseElement();
if (parameters is not null && parameters.Count > 0)
{
builder.OpenElement(10, "dl");
builder.AddAttribute(11, "class", "row mb-0 small");
builder.AddAttribute(12, "data-test", "sql-parameters");
// The analyzer (ASP0006) requires literal sequence numbers
// inside a render fragment. We delegate parameter rendering
// to a helper fragment that uses a stable @key per entry,
// so per-row diffing stays correct even though the outer
// sequence number is fixed.
builder.AddContent(13, BuildSqlParameterRows(parameters));
builder.CloseElement();
}
return;
}
// Generic JSON pretty-print path.
if (TryPrettyPrintJson(body, out var pretty))
{
builder.OpenElement(20, "pre");
builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json");
builder.AddContent(22, pretty);
builder.CloseElement();
return;
}
// Fallback: verbatim. Wrapping in preserves whitespace, which
// is useful when the body is multi-line plain text or a partial JSON.
builder.OpenElement(30, "pre");
builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
builder.AddContent(32, body);
builder.CloseElement();
};
private static RenderFragment BuildSqlParameterRows(List> parameters) => builder =>
{
foreach (var kv in parameters)
{
// Literal sequence numbers (ASP0006) + per-element SetKey so
// Blazor's diff is still keyed on parameter name. The "0" base
// is fine here — each loop iteration produces a disjoint
// dt/dd pair, and the diff keys on @key, not sequence.
builder.OpenElement(0, "dt");
builder.SetKey($"dt-{kv.Key}");
builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
builder.AddContent(2, kv.Key);
builder.CloseElement();
builder.OpenElement(3, "dd");
builder.SetKey($"dd-{kv.Key}");
builder.AddAttribute(4, "class", "col-8 font-monospace");
builder.AddContent(5, kv.Value);
builder.CloseElement();
}
};
private static bool TryPrettyPrintJson(string text, out string formatted)
{
formatted = text;
try
{
using var doc = JsonDocument.Parse(text);
formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
return true;
}
catch (JsonException)
{
return false;
}
}
private static string PrettyPrintJson(string text)
=> TryPrettyPrintJson(text, out var pretty) ? pretty : text;
///
/// Best-effort parse of a DbOutbound {sql, parameters} body.
/// Returns true only when the JSON has a string sql property;
/// parameters is treated as an optional object whose values
/// stringify to scalar text.
///
private static bool TryParseDbBody(string text, out string sql, out List>? parameters)
{
sql = string.Empty;
parameters = null;
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
return false;
sql = sqlProp.GetString() ?? string.Empty;
if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
&& paramsProp.ValueKind == JsonValueKind.Object)
{
parameters = new List>();
foreach (var p in paramsProp.EnumerateObject())
{
parameters.Add(new KeyValuePair(p.Name, StringifyJsonValue(p.Value)));
}
}
return true;
}
catch (JsonException)
{
return false;
}
}
private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Null => "null",
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Number => value.GetRawText(),
_ => value.GetRawText(),
};
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
private async Task CopyCurl()
{
if (Event is null) return;
var curl = BuildCurlCommand(Event);
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
}
catch
{
// Clipboard interop can fail (denied permission, prerender, etc.).
// The drawer stays open; the failure surfaces in the dev console
// only — we deliberately do not toast here because the parent
// page owns toast state.
}
}
private void ShowAllForOperation()
{
if (Event?.CorrelationId is not { } corr) return;
var uri = $"/audit/log?correlationId={corr}";
Navigation.NavigateTo(uri);
}
///
/// Drill-in to every audit row sharing this row's
/// — the universal per-run correlation value, distinct from the per-operation
/// CorrelationId drill-back above. Navigates to /audit/log?executionId={id},
/// which the page parses on init and auto-loads. The button is only rendered
/// when is non-null, so this is total.
///
private void ViewThisExecution()
{
if (Event?.ExecutionId is not { } exec) return;
var uri = $"/audit/log?executionId={exec}";
Navigation.NavigateTo(uri);
}
///
/// Build a cURL command from an audit event. The URL comes from
/// Target; when the RequestSummary parses as
/// {headers, body, method?}, headers fold into -H flags
/// and the body into --data-raw. Default method is POST for
/// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly.
///
private static string BuildCurlCommand(AuditEvent ev)
{
var sb = new StringBuilder();
sb.Append("curl");
string method = "POST";
List>? headers = null;
string? body = null;
if (!string.IsNullOrEmpty(ev.RequestSummary))
{
TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
}
sb.Append(' ').Append("-X ").Append(method);
if (headers is not null)
{
foreach (var (name, value) in headers)
{
sb.Append(' ').Append("-H ");
sb.Append(QuoteShellArg($"{name}: {value}"));
}
}
if (!string.IsNullOrEmpty(body))
{
sb.Append(' ').Append("--data-raw ");
sb.Append(QuoteShellArg(body!));
}
var url = ev.Target ?? string.Empty;
sb.Append(' ').Append(QuoteShellArg(url));
return sb.ToString();
}
private static void TryExtractCurlPartsFromJson(
string requestSummary,
ref string method,
ref List>? headers,
ref string? body)
{
try
{
using var doc = JsonDocument.Parse(requestSummary);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
{
method = m.GetString() ?? method;
}
if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
{
headers = new List>();
foreach (var h in hs.EnumerateObject())
{
var value = h.Value.ValueKind == JsonValueKind.String
? h.Value.GetString() ?? string.Empty
: h.Value.GetRawText();
headers.Add(new KeyValuePair(h.Name, value));
}
}
if (doc.RootElement.TryGetProperty("body", out var b))
{
body = b.ValueKind == JsonValueKind.String
? b.GetString()
: b.GetRawText();
}
}
catch (JsonException)
{
// RequestSummary wasn't the expected {headers, body} shape —
// just produce a bare cURL with no body/headers.
}
}
///
/// Quote a single shell argument with single quotes, escaping embedded
/// single quotes via the standard '\'' idiom. This is the same
/// quoting strategy curl examples use across man pages.
///
private static string QuoteShellArg(string value)
{
if (string.IsNullOrEmpty(value)) return "''";
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
return $"'{escaped}'";
}
}