Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs
T
Joseph Doherty b2cab59cb9 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.
2026-08-15 13:22:41 -04:00

154 lines
5.9 KiB
C#

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;
}