feat(cli): table output formatter for audit events (#23 M8)
This commit is contained in:
@@ -29,19 +29,16 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value. The
|
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value:
|
||||||
/// table formatter is filled in by Bundle C; until then <c>--format table</c> falls back
|
/// <c>table</c> renders a column-aligned text table (<see cref="TableAuditFormatter"/>),
|
||||||
/// to JSONL with a one-time notice so the flag is wired but not silently broken.
|
/// any other value (including <c>json</c>) renders JSONL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AuditFormatterFactory
|
public static class AuditFormatterFactory
|
||||||
{
|
{
|
||||||
public static IAuditFormatter Create(string format, TextWriter notices)
|
public static IAuditFormatter Create(string format, TextWriter notices)
|
||||||
{
|
{
|
||||||
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
return new TableAuditFormatter();
|
||||||
notices.WriteLine("note: 'table' output is not yet available; using json. (Bundle C)");
|
|
||||||
return new JsonLinesAuditFormatter();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonLinesAuditFormatter();
|
return new JsonLinesAuditFormatter();
|
||||||
}
|
}
|
||||||
|
|||||||
96
src/ScadaLink.CLI/Commands/TableAuditFormatter.cs
Normal file
96
src/ScadaLink.CLI/Commands/TableAuditFormatter.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
117
tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs
Normal file
117
tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Tests.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <c>table</c> output formatter of the <c>scadalink audit query</c>
|
||||||
|
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the
|
||||||
|
/// empty-result-set case, and null-actor handling.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditTableFormatterTests
|
||||||
|
{
|
||||||
|
private static IReadOnlyList<JsonElement> Events(string json)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
return doc.RootElement.EnumerateArray()
|
||||||
|
.Select(e => e.Clone())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Table_RendersHeaderRow_WithExpectedColumns()
|
||||||
|
{
|
||||||
|
var formatter = new TableAuditFormatter();
|
||||||
|
var output = new StringWriter();
|
||||||
|
|
||||||
|
formatter.WritePage(Events("[]"), output);
|
||||||
|
|
||||||
|
var firstLine = output.ToString()
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0];
|
||||||
|
foreach (var col in new[]
|
||||||
|
{
|
||||||
|
"OccurredAtUtc", "Channel", "Kind", "Status",
|
||||||
|
"Target", "Actor", "DurationMs", "HttpStatus",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Assert.Contains(col, firstLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Table_TruncatesLongTarget_WithEllipsis()
|
||||||
|
{
|
||||||
|
var formatter = new TableAuditFormatter();
|
||||||
|
var output = new StringWriter();
|
||||||
|
|
||||||
|
var longTarget = new string('x', 200);
|
||||||
|
formatter.WritePage(
|
||||||
|
Events($"[{{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
|
||||||
|
$"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"{longTarget}\"," +
|
||||||
|
$"\"actor\":\"multi-role\"}}]"),
|
||||||
|
output);
|
||||||
|
|
||||||
|
var text = output.ToString();
|
||||||
|
Assert.Contains("…", text);
|
||||||
|
// The full untruncated target must not appear verbatim.
|
||||||
|
Assert.DoesNotContain(longTarget, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
|
||||||
|
{
|
||||||
|
var formatter = new TableAuditFormatter();
|
||||||
|
var output = new StringWriter();
|
||||||
|
|
||||||
|
formatter.WritePage(Events("[]"), output);
|
||||||
|
|
||||||
|
var lines = output.ToString()
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
// Header only — no data rows. (A header line is always emitted so the
|
||||||
|
// column shape is visible even with zero results.)
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Contains("OccurredAtUtc", lines[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Table_NullActor_RendersBlank()
|
||||||
|
{
|
||||||
|
var formatter = new TableAuditFormatter();
|
||||||
|
var output = new StringWriter();
|
||||||
|
|
||||||
|
formatter.WritePage(
|
||||||
|
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"InboundApi\"," +
|
||||||
|
"\"kind\":\"ApiCall\",\"status\":\"Delivered\",\"target\":\"key-1\"," +
|
||||||
|
"\"actor\":null}]"),
|
||||||
|
output);
|
||||||
|
|
||||||
|
var lines = output.ToString()
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
Assert.Equal(2, lines.Length);
|
||||||
|
// The data row must not contain the literal "null" for the actor column.
|
||||||
|
Assert.DoesNotContain("null", lines[1]);
|
||||||
|
Assert.Contains("InboundApi", lines[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
|
||||||
|
{
|
||||||
|
var formatter = new TableAuditFormatter();
|
||||||
|
var output = new StringWriter();
|
||||||
|
|
||||||
|
formatter.WritePage(
|
||||||
|
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
|
||||||
|
"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"weather-api\"," +
|
||||||
|
"\"actor\":\"multi-role\",\"durationMs\":42,\"httpStatus\":200}," +
|
||||||
|
"{\"occurredAtUtc\":\"2026-05-20T12:01:00Z\",\"channel\":\"Notification\"," +
|
||||||
|
"\"kind\":\"Send\",\"status\":\"Failed\",\"target\":\"ops-list\"," +
|
||||||
|
"\"actor\":\"scheduler\",\"durationMs\":7}]"),
|
||||||
|
output);
|
||||||
|
|
||||||
|
var lines = output.ToString()
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
Assert.Equal(3, lines.Length);
|
||||||
|
Assert.Contains("weather-api", lines[1]);
|
||||||
|
Assert.Contains("ops-list", lines[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user