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:
@@ -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`.
|
||||
|
||||
```
|
||||
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
|
||||
@@ -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`
|
||||
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.
|
||||
|
||||
### 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;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
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" };
|
||||
cmd.Add(userOption);
|
||||
@@ -84,6 +91,7 @@ public static class AuditLogCommands
|
||||
cmd.Add(toOption);
|
||||
cmd.Add(pageOption);
|
||||
cmd.Add(pageSizeOption);
|
||||
cmd.Add(detailOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var user = result.GetValue(userOption);
|
||||
@@ -93,9 +101,11 @@ public static class AuditLogCommands
|
||||
var to = result.GetValue(toOption);
|
||||
var page = result.GetValue(pageOption);
|
||||
var pageSize = result.GetValue(pageSizeOption);
|
||||
var detail = result.GetValue(detailOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1801,6 +1801,7 @@ scadabridge --url <url> audit-config query [options]
|
||||
| `--to` | no | — | End timestamp in ISO 8601 format |
|
||||
| `--page` | no | `1` | Page number |
|
||||
| `--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:
|
||||
|
||||
@@ -1822,7 +1823,25 @@ Output shape:
|
||||
```
|
||||
|
||||
`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}).");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user