390 lines
15 KiB
C#
390 lines
15 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.JSInterop;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
|
|
namespace ScadaLink.CentralUI.Components.Audit;
|
|
|
|
/// <summary>
|
|
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
|
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer:
|
|
/// read-only fields, conditional Error/Request/Response/Extra subsections,
|
|
/// and action buttons (Copy as cURL, Show all events for this operation,
|
|
/// Close). The drawer is fully presentational — it has no DB or service
|
|
/// dependencies; the host page owns the open/close state.
|
|
///
|
|
/// <para>
|
|
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
|
|
/// The drawer pretty-prints JSON when it parses; falls back to verbatim
|
|
/// otherwise. DbOutbound payloads carry a <c>{sql, parameters}</c> JSON
|
|
/// shape and get a SQL code block plus a parameter definition list.
|
|
/// Syntax highlighting is CSS-class-only (<c>language-sql</c>); no JS
|
|
/// library is loaded — Blazor Server + Bootstrap only per the project's UI
|
|
/// rules.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Redaction badges.</b> The audit pipeline replaces redacted values
|
|
/// with the literal sentinels <c><redacted></c> or
|
|
/// <c><redacted: redactor error></c> (see Component-AuditLog.md
|
|
/// §Redaction). The drawer surfaces a yellow "Redacted" badge on a body
|
|
/// section when its text contains either sentinel — it does not attempt
|
|
/// to un-redact or count occurrences.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Copy as cURL.</b> Best-effort: the URL comes from <c>Target</c>;
|
|
/// when the RequestSummary parses as <c>{headers, body}</c>, headers are
|
|
/// folded into <c>-H</c> flags and the body into <c>--data-raw</c>. The
|
|
/// command is written to the system clipboard via
|
|
/// <see cref="IJSRuntime.InvokeVoidAsync(string, object?[])"/>. We only
|
|
/// surface the button for API channels (ApiOutbound / ApiInbound).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
|
|
/// the "Show all events" button navigates to
|
|
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
|
|
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
|
|
/// button navigates to <c>/audit/log?executionId={id}</c>. Both are deep
|
|
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
|
|
/// </para>
|
|
/// </summary>
|
|
public partial class AuditDrilldownDrawer
|
|
{
|
|
[Inject] private IJSRuntime JS { get; set; } = null!;
|
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
|
|
|
/// <summary>
|
|
/// The row to render. When null the drawer renders nothing — the host
|
|
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
|
/// </summary>
|
|
[Parameter] public AuditEvent? Event { get; set; }
|
|
|
|
/// <summary>
|
|
/// True when the host wants the drawer visible. We deliberately keep
|
|
/// this as a separate parameter from <see cref="Event"/>: an open
|
|
/// drawer briefly with a null event renders nothing (the row may still
|
|
/// be loading); a closed drawer with a stale event is the resting state.
|
|
/// </summary>
|
|
[Parameter] public bool IsOpen { get; set; }
|
|
|
|
/// <summary>
|
|
/// Fired when the user dismisses the drawer (close button or backdrop
|
|
/// click). The host is expected to flip <see cref="IsOpen"/> to false.
|
|
/// </summary>
|
|
[Parameter] public EventCallback OnClose { get; set; }
|
|
|
|
private const string RedactionSentinel = "<redacted>";
|
|
private const string RedactorErrorSentinel = "<redacted: redactor error>";
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Channel-aware body renderer. DbOutbound bodies that parse as
|
|
/// <c>{sql, parameters}</c> get a SQL block + parameter list; anything
|
|
/// else falls back to JSON-pretty-print, then plain-text verbatim.
|
|
/// </summary>
|
|
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 <pre> 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<KeyValuePair<string, string>> 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;
|
|
|
|
/// <summary>
|
|
/// Best-effort parse of a DbOutbound <c>{sql, parameters}</c> body.
|
|
/// Returns true only when the JSON has a string <c>sql</c> property;
|
|
/// <c>parameters</c> is treated as an optional object whose values
|
|
/// stringify to scalar text.
|
|
/// </summary>
|
|
private static bool TryParseDbBody(string text, out string sql, out List<KeyValuePair<string, string>>? 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<KeyValuePair<string, string>>();
|
|
foreach (var p in paramsProp.EnumerateObject())
|
|
{
|
|
parameters.Add(new KeyValuePair<string, string>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
|
|
/// — the universal per-run correlation value, distinct from the per-operation
|
|
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
|
|
/// which the page parses on init and auto-loads. The button is only rendered
|
|
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
|
|
/// </summary>
|
|
private void ViewThisExecution()
|
|
{
|
|
if (Event?.ExecutionId is not { } exec) return;
|
|
var uri = $"/audit/log?executionId={exec}";
|
|
Navigation.NavigateTo(uri);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a cURL command from an audit event. The URL comes from
|
|
/// <c>Target</c>; when the RequestSummary parses as
|
|
/// <c>{headers, body, method?}</c>, headers fold into <c>-H</c> flags
|
|
/// and the body into <c>--data-raw</c>. Default method is POST for
|
|
/// outbound audit rows — the audit pipeline does not always capture
|
|
/// the verb explicitly.
|
|
/// </summary>
|
|
private static string BuildCurlCommand(AuditEvent ev)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append("curl");
|
|
|
|
string method = "POST";
|
|
List<KeyValuePair<string, string>>? 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<KeyValuePair<string, string>>? 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<KeyValuePair<string, string>>();
|
|
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<string, string>(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.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Quote a single shell argument with single quotes, escaping embedded
|
|
/// single quotes via the standard <c>'\''</c> idiom. This is the same
|
|
/// quoting strategy curl examples use across man pages.
|
|
/// </summary>
|
|
private static string QuoteShellArg(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value)) return "''";
|
|
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
|
|
return $"'{escaped}'";
|
|
}
|
|
}
|