feat(ui): server-side streaming CSV export of Audit Log (#23 M7)

This commit is contained in:
Joseph Doherty
2026-05-20 20:57:01 -04:00
parent 943c2ced39
commit 8744630adb
10 changed files with 1163 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Security;
namespace ScadaLink.CentralUI.Audit;
/// <summary>
/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F).
///
/// <para>
/// CentralUI ships no MVC controllers (see <see cref="ScadaLink.CentralUI.Auth.AuthEndpoints"/>
/// and <see cref="ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisEndpoints"/>),
/// so the brief's "controller" is implemented as a minimal-API endpoint instead.
/// The endpoint streams to <c>Response.Body</c> directly so the export does NOT
/// buffer the full result set in memory — see
/// <see cref="IAuditLogExportService.ExportAsync"/>.
/// </para>
///
/// <para>
/// The route is admin-gated to mirror the NavMenu (<c>RequireAdmin</c> wraps
/// the Audit section). The query-string parser silently drops unrecognised
/// values to match the page-level parser in
/// <c>AuditLogPage.ApplyQueryStringFilters</c> — an unknown enum value yields
/// the same "no constraint" outcome rather than a 400.
/// </para>
/// </summary>
public static class AuditExportEndpoints
{
/// <summary>
/// Default row cap for a single export. Large enough to satisfy realistic
/// operator workflows; mirrors the brief's recommended ceiling. Operators
/// who need more should fall back to the CLI (footnote rendered in the
/// cap-footer line).
/// </summary>
public const int DefaultMaxRows = 100_000;
public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync)
.RequireAuthorization(AuthorizationPolicies.RequireAdmin);
return endpoints;
}
/// <summary>
/// Handles <c>GET /api/centralui/audit/export</c>. Internal so endpoint
/// tests can call it directly when desirable; the live wire-up goes
/// through the minimal-API map above.
/// </summary>
internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
{
var filter = ParseFilter(context.Request.Query);
var maxRows = ParseMaxRows(context.Request.Query);
// Stamp the response headers BEFORE the first body write so the client
// sees text/csv + an attachment download right away.
var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
context.Response.ContentType = "text/csv; charset=utf-8";
context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
// Defeat any intermediate buffering proxy so the operator sees rows
// streaming through as the server flushes each repository page.
context.Response.Headers["Cache-Control"] = "no-store";
await exportService.ExportAsync(filter, maxRows, context.Response.Body, context.RequestAborted);
}
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>.
/// Unknown enum names / un-parseable Guids / dates are silently dropped
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>).
/// </summary>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
AuditKind? kind = null;
if (query.TryGetValue("kind", out var kindValues)
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
{
kind = parsedKind;
}
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
string? site = TrimToNullable(query, "site");
string? target = TrimToNullable(query, "target");
string? actor = TrimToNullable(query, "actor");
Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues)
&& Guid.TryParse(corrValues.ToString(), out var parsedCorr))
{
correlationId = parsedCorr;
}
DateTime? fromUtc = ParseUtcDate(query, "from");
DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: site,
Target: target,
Actor: actor,
CorrelationId: correlationId,
FromUtc: fromUtc,
ToUtc: toUtc);
}
/// <summary>
/// Optional <c>maxRows=</c> query-string override. Falls back to
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable
/// value rather than erroring — same lax contract as the rest of the
/// query parser.
/// </summary>
private static int ParseMaxRows(IQueryCollection query)
{
if (query.TryGetValue("maxRows", out var raw)
&& int.TryParse(raw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
&& parsed > 0)
{
return parsed;
}
return DefaultMaxRows;
}
private static string? TrimToNullable(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
var v = values.ToString();
return string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}
private static DateTime? ParseUtcDate(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
if (DateTime.TryParse(
values.ToString(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
return null;
}
}

View File

@@ -19,6 +19,22 @@
InitialInstanceSearch="@_initialInstanceSearch" />
</div>
@* Export button (Bundle F / M7-T14). A plain <a download> link triggers the
streaming CSV endpoint at /api/centralui/audit/export — chosen over a
SignalR-driven download because the request can stream 100k rows directly
to the response body without buffering through the Blazor circuit. The
href reflects the most recently applied filter; before Apply is clicked,
an unconstrained export is exposed. *@
<div class="mb-3 d-flex justify-content-end">
<a class="btn btn-outline-secondary btn-sm"
href="@ExportUrl"
download
role="button"
aria-label="Export current view to CSV">
Export CSV
</a>
</div>
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's
drilldown drawer; the grid stays in "no events" mode until the user applies a
filter so the page does not auto-load the full audit table on first render. *@

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using ScadaLink.Commons.Entities.Audit;
@@ -158,4 +159,70 @@ public partial class AuditLogPage
// grid) shows the same row instantly without a re-render flicker.
_drawerOpen = false;
}
/// <summary>
/// Bundle F (M7-T14): URL the Export-CSV link points at. Renders the most
/// recently applied filter as query-string params so the server-side
/// streaming endpoint reproduces the user's current view. With no filter
/// applied yet, returns the bare endpoint — i.e. an unconstrained export.
/// </summary>
/// <remarks>
/// Built here rather than in markup so the per-row test coverage can
/// exercise the URL composition without booting the full Blazor renderer.
/// </remarks>
internal string ExportUrl => BuildExportUrl(_currentFilter);
internal static string BuildExportUrl(AuditLogQueryFilter? filter)
{
const string basePath = "/api/centralui/audit/export";
if (filter is null)
{
return basePath;
}
var parts = new List<KeyValuePair<string, string?>>(9);
if (filter.Channel is { } ch)
{
parts.Add(new("channel", ch.ToString()));
}
if (filter.Kind is { } kind)
{
parts.Add(new("kind", kind.ToString()));
}
if (filter.Status is { } status)
{
parts.Add(new("status", status.ToString()));
}
if (!string.IsNullOrWhiteSpace(filter.SourceSiteId))
{
parts.Add(new("site", filter.SourceSiteId));
}
if (!string.IsNullOrWhiteSpace(filter.Target))
{
parts.Add(new("target", filter.Target));
}
if (!string.IsNullOrWhiteSpace(filter.Actor))
{
parts.Add(new("actor", filter.Actor));
}
if (filter.CorrelationId is { } corr)
{
parts.Add(new("correlationId", corr.ToString()));
}
if (filter.FromUtc is { } from)
{
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
}
if (filter.ToUtc is { } to)
{
parts.Add(new("to", to.ToString("O", CultureInfo.InvariantCulture)));
}
if (parts.Count == 0)
{
return basePath;
}
return QueryHelpers.AddQueryString(basePath, parts);
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using ScadaLink.CentralUI.Audit;
using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Layout;
using ScadaLink.CentralUI.ScriptAnalysis;
@@ -17,6 +18,7 @@ public static class EndpointExtensions
{
endpoints.MapAuthEndpoints();
endpoints.MapScriptAnalysisEndpoints();
endpoints.MapAuditExportEndpoints();
endpoints.MapRazorComponents<TApp>()
.AddInteractiveServerRenderMode()

View File

@@ -32,6 +32,10 @@ public static class ServiceCollectionExtensions
// results grid can be tested with a stubbed query source.
services.AddScoped<IAuditLogQueryService, AuditLogQueryService>();
// Audit Log (#23 M7-T14 / Bundle F): server-side streaming CSV export.
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);

View File

@@ -0,0 +1,238 @@
using System.Globalization;
using System.Text;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.CentralUI.Services;
/// <summary>
/// Streaming CSV exporter for the Audit Log page (#23 M7-T14 / Bundle F).
///
/// <para>
/// The exporter iterates <see cref="IAuditLogRepository.QueryAsync"/> page by page
/// using its keyset cursor and writes each row to a destination
/// <see cref="Stream"/> as RFC 4180-compliant CSV. The output is flushed after
/// each page so a large export starts streaming to the client immediately
/// instead of buffering the whole result set in memory.
/// </para>
///
/// <para>
/// Output is capped at a caller-supplied <c>maxRows</c> ceiling; when the cap
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
/// exports.</c> footer line so an operator can tell a truncated download from
/// a complete one. The header row contains the 21 columns of
/// <see cref="AuditEvent"/> in declaration order.
/// </para>
/// </summary>
public interface IAuditLogExportService
{
/// <summary>
/// Streams a CSV export of the rows matching <paramref name="filter"/> to
/// <paramref name="output"/>, capping at <paramref name="maxRows"/>.
/// </summary>
/// <param name="filter">Repository filter to apply.</param>
/// <param name="maxRows">
/// Maximum number of data rows (excluding header / footer) to emit. The
/// service stops paging once this is reached and appends a cap footer.
/// </param>
/// <param name="output">Destination stream — typically the HTTP response body.</param>
/// <param name="ct">Cancellation token (e.g. <c>HttpContext.RequestAborted</c>).</param>
/// <param name="pageSize">
/// Optional override for the repository page size. Defaults to 1000 — large
/// enough to amortise the per-query overhead, small enough that one page in
/// memory is bounded.
/// </param>
Task ExportAsync(
AuditLogQueryFilter filter,
int maxRows,
Stream output,
CancellationToken ct,
int pageSize = AuditLogExportService.DefaultPageSize);
}
/// <inheritdoc cref="IAuditLogExportService"/>
public sealed class AuditLogExportService : IAuditLogExportService
{
/// <summary>Default rows pulled per repository round-trip.</summary>
public const int DefaultPageSize = 1000;
private readonly IAuditLogRepository _repository;
public AuditLogExportService(IAuditLogRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
/// <inheritdoc/>
public async Task ExportAsync(
AuditLogQueryFilter filter,
int maxRows,
Stream output,
CancellationToken ct,
int pageSize = DefaultPageSize)
{
ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(output);
if (maxRows <= 0) throw new ArgumentOutOfRangeException(nameof(maxRows), "maxRows must be positive.");
if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be positive.");
// UTF-8 no-BOM: Excel will still recognise the CSV but the file stays
// a clean ASCII-superset for downstream pipes / grep. The StreamWriter
// leaves the underlying stream open so the controller can decide when
// to dispose / complete it.
await using var writer = new StreamWriter(
output,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
bufferSize: 4096,
leaveOpen: true);
writer.NewLine = "\r\n"; // RFC 4180
// Header — 21 columns in AuditEvent declaration order.
await writer.WriteLineAsync(Header);
int written = 0;
AuditLogPaging cursor = new(PageSize: Math.Min(pageSize, maxRows));
while (written < maxRows)
{
// Honour cancellation BEFORE we kick off another round-trip — this
// is the deterministic cancellation point that the test pins on.
ct.ThrowIfCancellationRequested();
// Tighten the last page's size so we never pull more than the cap.
var remaining = maxRows - written;
var effectivePageSize = Math.Min(cursor.PageSize, remaining);
var pageCursor = cursor with { PageSize = effectivePageSize };
var page = await _repository.QueryAsync(filter, pageCursor, ct);
if (page.Count == 0)
{
break;
}
foreach (var evt in page)
{
if (written >= maxRows)
{
break;
}
await writer.WriteLineAsync(FormatCsvRow(evt));
written++;
}
// Push bytes through the StreamWriter buffer into the underlying
// stream so the client sees progress per-page instead of waiting
// for the full export to buffer up.
await writer.FlushAsync(ct);
await output.FlushAsync(ct);
// Last page (short read) — no more data to fetch.
if (page.Count < effectivePageSize)
{
break;
}
var last = page[^1];
cursor = new AuditLogPaging(
PageSize: pageSize,
AfterOccurredAtUtc: last.OccurredAtUtc,
AfterEventId: last.EventId);
}
if (written >= maxRows)
{
// Cap footer — visible to operators so a truncated download is
// distinguishable from a complete one. The "#" prefix keeps it
// out of the data columns; spreadsheet tools will surface it as
// a single-cell row.
await writer.WriteLineAsync(
$"# Capped at {maxRows.ToString(CultureInfo.InvariantCulture)} rows. Use the CLI for larger exports.");
await writer.FlushAsync(ct);
await output.FlushAsync(ct);
}
}
// ─────────────────────────────────────────────────────────────────────
// CSV helpers
// ─────────────────────────────────────────────────────────────────────
/// <summary>The 21 column names in <see cref="AuditEvent"/> declaration order.</summary>
internal const string Header =
"EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," +
"SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," +
"HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," +
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
/// <summary>
/// Serialises one <see cref="AuditEvent"/> as a CSV row (no trailing newline).
/// Each nullable column renders as the empty string when null; non-null
/// scalars use invariant culture so an export taken on one locale parses
/// cleanly on another.
/// </summary>
internal static string FormatCsvRow(AuditEvent evt)
{
var sb = new StringBuilder(256);
AppendField(sb, evt.EventId.ToString(), first: true);
AppendField(sb, FormatDate(evt.OccurredAtUtc));
AppendField(sb, FormatDate(evt.IngestedAtUtc));
AppendField(sb, evt.Channel.ToString());
AppendField(sb, evt.Kind.ToString());
AppendField(sb, evt.CorrelationId?.ToString());
AppendField(sb, evt.SourceSiteId);
AppendField(sb, evt.SourceInstanceId);
AppendField(sb, evt.SourceScript);
AppendField(sb, evt.Actor);
AppendField(sb, evt.Target);
AppendField(sb, evt.Status.ToString());
AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture));
AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture));
AppendField(sb, evt.ErrorMessage);
AppendField(sb, evt.ErrorDetail);
AppendField(sb, evt.RequestSummary);
AppendField(sb, evt.ResponseSummary);
AppendField(sb, evt.PayloadTruncated.ToString());
AppendField(sb, evt.Extra);
AppendField(sb, evt.ForwardState?.ToString());
return sb.ToString();
}
private static string? FormatDate(DateTime? value) =>
value?.ToString("O", CultureInfo.InvariantCulture);
private static string FormatDate(DateTime value) =>
value.ToString("O", CultureInfo.InvariantCulture);
private static void AppendField(StringBuilder sb, string? value, bool first = false)
{
if (!first) sb.Append(',');
if (string.IsNullOrEmpty(value))
{
return;
}
// RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes.
bool needsQuoting = value.IndexOfAny(s_quoteTriggers) >= 0;
if (!needsQuoting)
{
sb.Append(value);
return;
}
sb.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
sb.Append('"').Append('"');
}
else
{
sb.Append(ch);
}
}
sb.Append('"');
}
private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' };
}