feat(ui): AuditDrilldownDrawer with JSON/SQL render, cURL, drill-back, redaction badges (#23 M7)

Implements Bundle C (M7-T4 through M7-T8) of the Audit Log #23 M7
Central UI work: a right-side off-canvas drawer that opens from
AuditResultsGrid row clicks and renders one AuditEvent in full.

Cohesive single-component delivery:
- Read-only fields stacked (form-layout memory): Channel/Kind, Status,
  HttpStatus, Target, Actor, Source* provenance, CorrelationId,
  OccurredAtUtc, IngestedAtUtc, DurationMs.
- Channel-aware body renderer: DbOutbound {sql, parameters} payloads
  render a code-block with CSS-only .language-sql class plus a
  parameter <dl>; other channels JSON-pretty-print when parseable and
  fall back to verbatim <pre>.
- Redaction badges on Request/Response when the body contains the
  <redacted> or <redacted: redactor error> sentinels.
- Copy-as-cURL (API channels only) builds a curl command from Target
  + optional {method, headers, body} RequestSummary JSON and writes
  it via navigator.clipboard.writeText.
- Show-all-events drill-back navigates to /audit/log?correlationId={id}
  when the event carries a CorrelationId.
- Close button + backdrop-click both raise OnClose.

AuditLogPage wires Event/IsOpen/OnClose; row clicks now open the
drawer (HandleRowSelected pins _selectedEvent + _drawerOpen=true).

11 bUnit tests cover field rendering, JSON pretty-print, verbatim
fallback, SQL block, conditional buttons, redaction badges,
navigation drill-back, and clipboard interop. No third-party UI
libraries: Bootstrap offcanvas + scoped razor.css only.
This commit is contained in:
Joseph Doherty
2026-05-20 20:13:33 -04:00
parent e052aa4ff8
commit ae4480e7aa
6 changed files with 847 additions and 3 deletions

View File

@@ -0,0 +1,161 @@
@using ScadaLink.Commons.Entities.Audit
@using ScadaLink.Commons.Types.Enums
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
All form/field rendering follows the form-layout memory:
read-only fields first (definition list), then subsections stacked,
action buttons at the bottom of the drawer. *@
@if (IsOpen && Event is not null)
{
<div class="offcanvas-backdrop fade show" data-test="drawer-backdrop"
@onclick="HandleClose"></div>
<div class="offcanvas offcanvas-end show audit-drilldown-drawer"
tabindex="-1"
style="visibility: visible;"
data-test="audit-drilldown-drawer">
<div class="offcanvas-header border-bottom">
<div>
<div class="text-muted small text-uppercase">Audit event</div>
<h5 class="offcanvas-title mb-0">Audit Event @ShortEventId(Event.EventId)</h5>
</div>
<button type="button" class="btn-close" aria-label="Close"
data-test="drawer-close"
@onclick="HandleClose"></button>
</div>
<div class="offcanvas-body small">
@* Read-only field list — primary identification + provenance. *@
<dl class="row mb-3" data-test="drawer-fields">
<dt class="col-4 text-muted fw-normal">Channel / Kind</dt>
<dd class="col-8" data-test="field-Channel">@Event.Channel / @Event.Kind</dd>
<dt class="col-4 text-muted fw-normal">Status</dt>
<dd class="col-8" data-test="field-Status">@Event.Status</dd>
<dt class="col-4 text-muted fw-normal">HttpStatus</dt>
<dd class="col-8 font-monospace" data-test="field-HttpStatus">@(Event.HttpStatus?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Target</dt>
<dd class="col-8" data-test="field-Target">@(Event.Target ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Actor</dt>
<dd class="col-8" data-test="field-Actor">@(Event.Actor ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceScript</dt>
<dd class="col-8" data-test="field-SourceScript">@(Event.SourceScript ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
<dt class="col-4 text-muted fw-normal">IngestedAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-IngestedAtUtc">@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")</dd>
<dt class="col-4 text-muted fw-normal">DurationMs</dt>
<dd class="col-8 font-monospace" data-test="field-DurationMs">@(Event.DurationMs?.ToString() ?? "—")</dd>
</dl>
@* Error subsection — only shown when there is something to report. *@
@if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail))
{
<section class="mb-3" data-test="section-error">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Error</h6>
@if (!string.IsNullOrEmpty(Event.ErrorMessage))
{
<p class="text-danger mb-1">@Event.ErrorMessage</p>
}
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
{
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
}
</section>
}
@* Request body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.RequestSummary))
{
<section class="mb-3" data-test="section-request">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Request</span>
@if (IsRedacted(Event.RequestSummary))
{
<span data-test="redaction-badge-request"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="request-body">
@RenderBody(Event.RequestSummary!, Event.Channel)
</div>
</section>
}
@* Response body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.ResponseSummary))
{
<section class="mb-3" data-test="section-response">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Response</span>
@if (IsRedacted(Event.ResponseSummary))
{
<span data-test="redaction-badge-response"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="response-body">
@RenderBody(Event.ResponseSummary!, Event.Channel)
</div>
</section>
}
@* Extra is always JSON when present. *@
@if (!string.IsNullOrEmpty(Event.Extra))
{
<section class="mb-3" data-test="section-extra">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
</section>
}
</div>
@* Action buttons at the bottom per form-layout memory. *@
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
@if (IsApiChannel(Event.Channel))
{
<button class="btn btn-outline-secondary btn-sm"
data-test="copy-as-curl"
@onclick="CopyCurl">
Copy as cURL
</button>
}
@if (Event.CorrelationId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="show-all-events"
@onclick="ShowAllForOperation">
Show all events for this operation
</button>
}
<button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer"
@onclick="HandleClose">
Close
</button>
</div>
</div>
}