feat(cli): table output formatter for audit events (#23 M8)

This commit is contained in:
Joseph Doherty
2026-05-20 22:00:57 -04:00
parent 4b3a692170
commit d40ee85e14
3 changed files with 217 additions and 7 deletions

View File

@@ -29,19 +29,16 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter
}
/// <summary>
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value. The
/// table formatter is filled in by Bundle C; until then <c>--format table</c> falls back
/// to JSONL with a one-time notice so the flag is wired but not silently broken.
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value:
/// <c>table</c> renders a column-aligned text table (<see cref="TableAuditFormatter"/>),
/// any other value (including <c>json</c>) renders JSONL.
/// </summary>
public static class AuditFormatterFactory
{
public static IAuditFormatter Create(string format, TextWriter notices)
{
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
{
notices.WriteLine("note: 'table' output is not yet available; using json. (Bundle C)");
return new JsonLinesAuditFormatter();
}
return new TableAuditFormatter();
return new JsonLinesAuditFormatter();
}

View File

@@ -0,0 +1,96 @@
using System.Text.Json;
namespace ScadaLink.CLI.Commands;
/// <summary>
/// Human-readable table formatter for <c>audit query --format table</c> (Audit Log
/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed
/// column set (<see cref="Columns"/>). Long free-text fields (Target, Actor) are
/// truncated with an ellipsis so columns stay aligned regardless of payload size.
/// </summary>
/// <remarks>
/// A header row is emitted once per page (matching the streamable, page-at-a-time
/// contract of <see cref="IAuditFormatter"/>). An empty page emits the header only,
/// so the column shape is visible even with zero results.
/// </remarks>
public sealed class TableAuditFormatter : IAuditFormatter
{
/// <summary>JSON property name (camelCase, as the server serializes it) → column header.</summary>
private static readonly (string Property, string Header, int MaxWidth)[] Columns =
{
("occurredAtUtc", "OccurredAtUtc", 24),
("channel", "Channel", 14),
("kind", "Kind", 18),
("status", "Status", 12),
("target", "Target", 32),
("actor", "Actor", 20),
("durationMs", "DurationMs", 10),
("httpStatus", "HttpStatus", 10),
};
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
{
// Build every cell first so column widths account for the actual data.
var rows = new List<string[]>(events.Count);
foreach (var evt in events)
{
var cells = new string[Columns.Length];
for (var i = 0; i < Columns.Length; i++)
cells[i] = Truncate(CellValue(evt, Columns[i].Property), Columns[i].MaxWidth);
rows.Add(cells);
}
var widths = new int[Columns.Length];
for (var i = 0; i < Columns.Length; i++)
widths[i] = Columns[i].Header.Length;
foreach (var row in rows)
for (var i = 0; i < Columns.Length; i++)
widths[i] = Math.Max(widths[i], row[i].Length);
WriteRow(output, Columns.Select(c => c.Header).ToArray(), widths);
foreach (var row in rows)
WriteRow(output, row, widths);
}
/// <summary>
/// Extracts a cell value for <paramref name="property"/> from an audit event.
/// A missing property or a JSON <c>null</c> renders as an empty string (never
/// the literal text "null").
/// </summary>
private static string CellValue(JsonElement evt, string property)
{
if (evt.ValueKind != JsonValueKind.Object
|| !evt.TryGetProperty(property, out var value)
|| value.ValueKind == JsonValueKind.Null)
{
return string.Empty;
}
return value.ValueKind == JsonValueKind.String
? value.GetString() ?? string.Empty
: value.ToString();
}
/// <summary>
/// Truncates <paramref name="value"/> to <paramref name="maxWidth"/> characters,
/// replacing the tail with a single-character ellipsis so the column stays aligned.
/// </summary>
private static string Truncate(string value, int maxWidth)
{
if (maxWidth <= 0 || value.Length <= maxWidth)
return value;
if (maxWidth == 1)
return "…";
return value.Substring(0, maxWidth - 1) + "…";
}
private static void WriteRow(TextWriter output, IReadOnlyList<string> cells, int[] widths)
{
for (var i = 0; i < cells.Count; i++)
{
// Last column is not padded — avoids trailing whitespace at line end.
output.Write(i == cells.Count - 1 ? cells[i] : cells[i].PadRight(widths[i] + 2));
}
output.WriteLine();
}
}