From b2cab59cb9ac982bdd5566765672585ade449d9b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 13:22:41 -0400 Subject: [PATCH] feat(cli): compact table projection for `audit-config query` (register row 57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/requirements/Component-CLI.md | 12 +- .../Commands/AuditConfigTableProjection.cs | 153 +++++++++++++++ .../Commands/AuditLogCommands.cs | 12 +- src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 21 +- .../AuditConfigTableProjectionTests.cs | 182 ++++++++++++++++++ 5 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index 1b209303..068cfb16 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -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 ] [--entity-type ] [--action ] [--from ] [--to ] [--page ] [--page-size ] +scadabridge audit-config query [--user ] [--entity-type ] [--action ] [--from ] [--to ] [--page ] [--page-size ] [--detail] ``` The response is an `AuditLogQueryResult` — `{ "entries": [ … ], "totalCount": }`, 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 diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs new file mode 100644 index 00000000..f4f3aa8c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; + +/// +/// Compact table projection for audit-config query (deferred-work register row 57). +/// +/// +/// The handler returns an AuditLogQueryResult — { "entries": [ … ], +/// "totalCount": N }. Handed to the generic renderer unprojected, that object takes +/// the Property/Value branch and the whole entries array lands in a single cell as +/// raw JSON, so --format table was unreadable. This unwraps the envelope to the +/// row array the renderer expects and drops afterStateJson, the per-entry snapshot +/// of the entire changed entity — the same "one fat field makes the table useless" problem +/// solves by dropping child collections. +/// +/// +/// +/// totalCount would be lost by a plain unwrap, so when the page is a strict subset +/// of the matches a single trailing row carries a note 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. +/// +/// +/// +/// JSON output is never touched (this runs only on the table path), and the command's +/// --detail flag skips the projection to render the raw payload as-is — that is +/// the way to see afterStateJson in table form. +/// +/// +internal static class AuditConfigTableProjection +{ + /// + /// Projects an AuditLogQueryResult 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. + /// + /// The raw success JSON body from the management API. + /// Compact JSON suitable for table rendering. + 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; + } + + /// Projects a single audit entry to its compact row node. + 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; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditLogCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditLogCommands.cs index 8fa8948b..b2b20892 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditLogCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditLogCommands.cs @@ -75,6 +75,13 @@ public static class AuditLogCommands pageOption.DefaultValueFactory = _ => 1; var pageSizeOption = new Option("--page-size") { Description = "Page size" }; pageSizeOption.DefaultValueFactory = _ => 50; + var detailOption = new Option("--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; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 0f94170a..dce85eee 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -1801,6 +1801,7 @@ scadabridge --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). --- diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs new file mode 100644 index 00000000..7e469999 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs @@ -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; + +/// +/// Tests for the compact audit-config query table projection (deferred-work +/// register row 57). The command passed no tableProjector, so the generic +/// renderer saw an OBJECT, took its Property/Value +/// branch, and printed the entire entries array as raw JSON inside one cell. +/// The projection unwraps the envelope to a row array, drops the per-entry +/// afterStateJson snapshot, and keeps totalCount visible as a trailing note. +/// +public class AuditConfigTableProjectionTests +{ + /// + /// The real server shape, serialised from the REAL + /// / types rather than hand-written JSON, so renaming one + /// of their properties fails this test instead of silently blanking a column. + /// + 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(), 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 = "proxy error"; + 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})."); + } +}