feat(cli): compact table projection for audit-config query (register row 57)

The command passed no `tableProjector`, so `--format table` handed the renderer
an `AuditLogQueryResult` OBJECT. That takes the generic Property/Value branch,
which printed the entire `entries` array as raw JSON inside a single cell. JSON
output was and remains correct, and is the primary format for this command.

Adds `AuditConfigTableProjection`, mirroring `TemplateTableProjection`:
string->string over the raw body, applied only on the table path, returning the
input verbatim when it is not JSON or not a recognised shape so the renderer's
own fallbacks still apply. It unwraps the envelope to a row array — id,
timestamp, user, action, entityType, entityName, entityId — and drops
`afterStateJson`, the full post-change entity snapshot, which is the audit
analogue of template's fat child arrays: one per row fills a terminal.
`bundleImportId` is dropped as mostly-null noise.

`totalCount` would be lost by a plain unwrap, so a truncated page gains a single
trailing row with a `note` cell ("showing 50 of 137 — use --page / --page-size
for the rest"). When the page IS the whole result set the column is not emitted
at all, so a small query's table stays exactly as narrow as its data, and the
note row carries no `id` so it cannot be mistaken for an entry.

A `--detail` flag skips the projection and renders the raw payload, matching
`template list` / `template get` — that plus `--format json` is how to reach
`afterStateJson`. A bare array root is also accepted, so the projection does not
regress if the payload is ever unwrapped upstream or the deprecated `audit-log`
alias meets an older server.

Tests build their fixtures by serialising the REAL AuditLogQueryResult /
AuditLogEntry types, so renaming a server property fails the test instead of
silently blanking a column. CLI README + Component-CLI.md document the table
shape, the omitted fields, and --detail.
This commit is contained in:
Joseph Doherty
2026-08-15 13:22:41 -04:00
parent be29bf471e
commit b2cab59cb9
5 changed files with 377 additions and 3 deletions
+11 -1
View File
@@ -238,7 +238,7 @@ record of admin edits). `audit-log` is a deprecated alias that still works but e
warning; migrate scripts to `audit-config`. warning; migrate scripts to `audit-config`.
``` ```
scadabridge audit-config query [--user <username>] [--entity-type <type>] [--action <action>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>] scadabridge audit-config query [--user <username>] [--entity-type <type>] [--action <action>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>] [--detail]
``` ```
The response is an `AuditLogQueryResult` — `{ "entries": [ … ], "totalCount": <n> }`, where The response is an `AuditLogQueryResult` — `{ "entries": [ … ], "totalCount": <n> }`, where
@@ -248,6 +248,16 @@ because the handler must not return the repository's `(Entries, TotalCount)` Val
`{}` and every query looks empty regardless of how many rows matched. `ManagementActor.SerializeResult` `{}` and every query looks empty regardless of how many rows matched. `ManagementActor.SerializeResult`
now rejects tuple results outright so the failure mode cannot recur silently. now rejects tuple results outright so the failure mode cannot recur silently.
JSON is the primary format here and is never altered by the table path. `--format table`
applies a compact projection (`AuditConfigTableProjection`) that unwraps the envelope into
one row per entry — `id`, `timestamp`, `user`, `action`, `entityType`, `entityName`,
`entityId` — and drops `afterStateJson`, the full post-change entity snapshot, since a
single one fills the terminal. Without a projector the renderer took its object branch and
printed the whole `entries` array as raw JSON inside one `Property`/`Value` cell (register
row 57). `totalCount` survives the unwrap as a trailing `note` row, emitted only when the
page is a strict subset of the matches. `--detail` skips the projection and renders the
raw payload, mirroring `template list`/`template get`.
The new centralized Audit Log component (#23) is exposed via the `scadabridge audit` group below. The new centralized Audit Log component (#23) is exposed via the `scadabridge audit` group below.
### Centralized Audit Commands ### Centralized Audit Commands
@@ -0,0 +1,153 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Compact table projection for <c>audit-config query</c> (deferred-work register row 57).
///
/// <para>
/// The handler returns an <c>AuditLogQueryResult</c> — <c>{ "entries": [ … ],
/// "totalCount": N }</c>. Handed to the generic renderer unprojected, that object takes
/// the Property/Value branch and the whole <c>entries</c> array lands in a single cell as
/// raw JSON, so <c>--format table</c> was unreadable. This unwraps the envelope to the
/// row array the renderer expects and drops <c>afterStateJson</c>, the per-entry snapshot
/// of the entire changed entity — the same "one fat field makes the table useless" problem
/// <see cref="TemplateTableProjection"/> solves by dropping child collections.
/// </para>
///
/// <para>
/// <c>totalCount</c> would be lost by a plain unwrap, so when the page is a strict subset
/// of the matches a single trailing row carries a <c>note</c> cell saying how many of how
/// many are shown. Every real entry leaves that cell empty, and when the page IS the whole
/// result set the column is not emitted at all — so the common small-query table stays
/// exactly as narrow as its data.
/// </para>
///
/// <para>
/// JSON output is never touched (this runs only on the table path), and the command's
/// <c>--detail</c> flag skips the projection to render the raw payload as-is — that is
/// the way to see <c>afterStateJson</c> in table form.
/// </para>
/// </summary>
internal static class AuditConfigTableProjection
{
/// <summary>
/// Projects an <c>AuditLogQueryResult</c> envelope (or a bare entry array) to the
/// compact row array the table renderer consumes. Returns the input unchanged when it
/// is not JSON or not one of those shapes, so the renderer's own fallbacks still apply.
/// </summary>
/// <param name="json">The raw success JSON body from the management API.</param>
/// <returns>Compact JSON suitable for table rendering.</returns>
internal static string ProjectEntries(string json)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException)
{
// Not JSON (e.g. a proxy error page) — let the renderer print it verbatim.
return json;
}
using (doc)
{
var root = doc.RootElement;
// A bare array is accepted so the projection still works if the payload is
// ever unwrapped upstream, and so the deprecated `audit-log` alias against an
// older server does not render worse than it does today.
if (root.ValueKind == JsonValueKind.Array)
{
return BuildRows(root, totalCount: null).ToJsonString();
}
if (root.ValueKind == JsonValueKind.Object
&& TryGetPropertyCI(root, "entries", out var entries)
&& entries.ValueKind == JsonValueKind.Array)
{
int? total = TryGetPropertyCI(root, "totalCount", out var tc)
&& tc.ValueKind == JsonValueKind.Number
&& tc.TryGetInt32(out var n)
? n
: null;
return BuildRows(entries, total).ToJsonString();
}
// Some other object (e.g. an error envelope) — leave it to the renderer.
return json;
}
}
private static JsonArray BuildRows(JsonElement entries, int? totalCount)
{
var arr = new JsonArray();
var shown = 0;
foreach (var item in entries.EnumerateArray())
{
arr.Add(ProjectElement(item));
shown++;
}
// Only when the page is a strict subset — otherwise the count is just the row
// count the operator can already see, and the extra column would be noise.
if (totalCount is int total && total > shown)
{
arr.Add(new JsonObject
{
["note"] = JsonValue.Create(
$"showing {shown} of {total} — use --page / --page-size for the rest"),
});
}
return arr;
}
/// <summary>Projects a single audit entry to its compact row node.</summary>
private static JsonNode ProjectElement(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
return JsonValue.Create(element.ToString())!;
// JsonObject preserves insertion order, which fixes the table's column order.
// afterStateJson (the full post-change entity snapshot) and bundleImportId are
// deliberately omitted — `--detail`, or --format json, for those.
return new JsonObject
{
["id"] = Int(element, "id"),
["timestamp"] = Str(element, "timestamp"),
["user"] = Str(element, "user"),
["action"] = Str(element, "action"),
["entityType"] = Str(element, "entityType"),
["entityName"] = Str(element, "entityName"),
["entityId"] = Str(element, "entityId"),
};
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
private static JsonNode? Str(JsonElement obj, string name)
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String
? JsonValue.Create(v.GetString())
: null;
private static JsonNode? Int(JsonElement obj, string name)
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n)
? JsonValue.Create(n)
: null;
}
@@ -75,6 +75,13 @@ public static class AuditLogCommands
pageOption.DefaultValueFactory = _ => 1; pageOption.DefaultValueFactory = _ => 1;
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" }; var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
pageSizeOption.DefaultValueFactory = _ => 50; pageSizeOption.DefaultValueFactory = _ => 50;
var detailOption = new Option<bool>("--detail")
{
Description = "Render the raw query payload in table output instead of the compact column projection. "
+ "This is how to see 'afterStateJson' (the full post-change entity snapshot) in table form — it is "
+ "omitted from the compact columns because a single entry's snapshot fills the terminal. "
+ "No effect on JSON output."
};
var cmd = new Command("query") { Description = "Query audit log entries" }; var cmd = new Command("query") { Description = "Query audit log entries" };
cmd.Add(userOption); cmd.Add(userOption);
@@ -84,6 +91,7 @@ public static class AuditLogCommands
cmd.Add(toOption); cmd.Add(toOption);
cmd.Add(pageOption); cmd.Add(pageOption);
cmd.Add(pageSizeOption); cmd.Add(pageSizeOption);
cmd.Add(detailOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var user = result.GetValue(userOption); var user = result.GetValue(userOption);
@@ -93,9 +101,11 @@ public static class AuditLogCommands
var to = result.GetValue(toOption); var to = result.GetValue(toOption);
var page = result.GetValue(pageOption); var page = result.GetValue(pageOption);
var pageSize = result.GetValue(pageSizeOption); var pageSize = result.GetValue(pageSizeOption);
var detail = result.GetValue(detailOption);
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize)); new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize),
tableProjector: detail ? null : AuditConfigTableProjection.ProjectEntries);
}); });
return cmd; return cmd;
} }
+20 -1
View File
@@ -1801,6 +1801,7 @@ scadabridge --url <url> audit-config query [options]
| `--to` | no | — | End timestamp in ISO 8601 format | | `--to` | no | — | End timestamp in ISO 8601 format |
| `--page` | no | `1` | Page number | | `--page` | no | `1` | Page number |
| `--page-size` | no | `50` | Results per page | | `--page-size` | no | `50` | Results per page |
| `--detail` | no | `false` | Table output only: render the raw payload instead of the compact column projection (see below). No effect on JSON output. |
Output shape: Output shape:
@@ -1822,7 +1823,25 @@ Output shape:
``` ```
`totalCount` is the unpaged number of rows matching the filters, so a client can page `totalCount` is the unpaged number of rows matching the filters, so a client can page
without a second call. without a second call. JSON is the primary format for this command and is what scripts
should consume — it is never altered by the table projection below.
**Table output.** `--format table` renders a compact projection with one row per entry:
```
id timestamp user action entityType entityName entityId
412 2026-08-15T12:00:00+00:00 multi-role Created Template PumpTemplate 7
413 2026-08-15T12:05:00+00:00 designer Deleted ExternalSystem MES 3
```
`afterStateJson` (the full post-change entity snapshot) and `bundleImportId` are omitted
— one snapshot per row fills a terminal. Pass `--detail` to render the raw payload
instead, or use `--format json`. When the page is a strict subset of the matches, a final
row carries a `note` cell (`showing 50 of 137 — use --page / --page-size for the rest`);
when the page is the whole result set that column is not emitted at all.
Before this projection existed the whole `entries` array was printed as raw JSON inside a
single `Property`/`Value` cell (deferred-work register row 57).
--- ---
@@ -0,0 +1,182 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Tests for the compact <c>audit-config query</c> table projection (deferred-work
/// register row 57). The command passed no <c>tableProjector</c>, so the generic
/// renderer saw an <see cref="AuditLogQueryResult"/> OBJECT, took its Property/Value
/// branch, and printed the entire <c>entries</c> array as raw JSON inside one cell.
/// The projection unwraps the envelope to a row array, drops the per-entry
/// <c>afterStateJson</c> snapshot, and keeps <c>totalCount</c> visible as a trailing note.
/// </summary>
public class AuditConfigTableProjectionTests
{
/// <summary>
/// The real server shape, serialised from the REAL <see cref="AuditLogQueryResult"/>
/// / <see cref="AuditLogEntry"/> types rather than hand-written JSON, so renaming one
/// of their properties fails this test instead of silently blanking a column.
/// </summary>
private static string RealPayload(int totalCount) =>
JsonSerializer.Serialize(
new AuditLogQueryResult(
new[]
{
new AuditLogEntry("multi-role", "Created", "Template", "7", "PumpTemplate")
{
Id = 412,
AfterStateJson = "{\"id\":7,\"name\":\"PumpTemplate\",\"attributes\":[]}",
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero),
},
new AuditLogEntry("designer", "Deleted", "ExternalSystem", "3", "MES")
{
Id = 413,
AfterStateJson = null,
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 5, 0, TimeSpan.Zero),
},
},
totalCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
[Fact]
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
var root = doc.RootElement;
// An ARRAY is the whole point — the object envelope is what made the renderer
// print one Property/Value row with the entries blob in a single cell.
Assert.Equal(JsonValueKind.Array, root.ValueKind);
Assert.Equal(2, root.GetArrayLength());
var first = root[0];
Assert.Equal(412, first.GetProperty("id").GetInt32());
Assert.Equal("multi-role", first.GetProperty("user").GetString());
Assert.Equal("Created", first.GetProperty("action").GetString());
Assert.Equal("Template", first.GetProperty("entityType").GetString());
Assert.Equal("PumpTemplate", first.GetProperty("entityName").GetString());
Assert.Equal("7", first.GetProperty("entityId").GetString());
Assert.Contains("2026-08-15", first.GetProperty("timestamp").GetString());
var second = root[1];
Assert.Equal("Deleted", second.GetProperty("action").GetString());
Assert.Equal("MES", second.GetProperty("entityName").GetString());
}
[Fact]
public void ProjectEntries_DropsAfterStateJson()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
// The fat field must NOT survive — one entity snapshot per row fills the terminal,
// which is the audit analogue of template's child arrays.
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 137));
using var doc = JsonDocument.Parse(compact);
var root = doc.RootElement;
// Two entries plus the note row.
Assert.Equal(3, root.GetArrayLength());
var note = root[2].GetProperty("note").GetString();
Assert.Contains("2 of 137", note);
// The note must not masquerade as an entry.
Assert.False(root[2].TryGetProperty("id", out _));
}
[Fact]
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
{
// When the page IS the result set the count adds nothing the operator cannot
// already see, so the column must not appear and widen every small query.
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
Assert.Equal(2, doc.RootElement.GetArrayLength());
Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
}
[Fact]
public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
{
var json = JsonSerializer.Serialize(
new AuditLogQueryResult(Array.Empty<AuditLogEntry>(), 0),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var compact = AuditConfigTableProjection.ProjectEntries(json);
using var doc = JsonDocument.Parse(compact);
// The renderer prints "(no results)" for an empty array; an empty OBJECT would
// have printed an empty Property/Value table instead.
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public void ProjectEntries_BareArray_IsStillProjected()
{
// Tolerated so the projection does not regress if the payload is ever unwrapped
// upstream, or when the deprecated `audit-log` alias hits an older server.
const string bare = """
[ { "id": 1, "user": "u", "action": "Updated", "entityType": "Site",
"entityName": "SiteA", "entityId": "2", "timestamp": "2026-08-15T12:00:00+00:00",
"afterStateJson": "{\"big\":true}" } ]
""";
var compact = AuditConfigTableProjection.ProjectEntries(bare);
using var doc = JsonDocument.Parse(compact);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal("SiteA", doc.RootElement[0].GetProperty("entityName").GetString());
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
}
[Fact]
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
{
// e.g. an error envelope — leave it to the renderer's own fallbacks rather than
// projecting it into an empty table that hides the message.
const string other = """{ "error": "boom" }""";
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
}
[Fact]
public void ProjectEntries_NonJson_ReturnedVerbatim()
{
const string notJson = "<html>proxy error</html>";
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
}
[Fact]
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
{
// The reported symptom was the whole entries array in one cell. A page of rows
// carrying real afterStateJson snapshots must collapse hard.
var entries = Enumerable.Range(0, 50).Select(i =>
new AuditLogEntry("designer", "Updated", "Template", i.ToString(), $"Template{i}")
{
Id = i,
AfterStateJson = new string('x', 2000),
Timestamp = DateTimeOffset.UnixEpoch.AddMinutes(i),
}).ToArray();
var json = JsonSerializer.Serialize(new AuditLogQueryResult(entries, entries.Length),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var compact = AuditConfigTableProjection.ProjectEntries(json);
Assert.True(compact.Length * 4 < json.Length,
$"Expected compact ({compact.Length}) to be far smaller than raw ({json.Length}).");
}
}