using System.Text; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// /// Arguments for an audit tree invocation. /// public sealed class AuditTreeArgs { /// /// The execution ID (GUID) to look up. May be any node in the chain — the /// server walks to the root and returns the full tree. /// public string ExecutionId { get; set; } = string.Empty; } /// /// Represents one execution node as returned by GET /api/audit/tree. /// Property names match the server's camelCase JSON serialisation of /// ExecutionTreeNode. /// internal sealed class AuditTreeNodeDto { public Guid ExecutionId { get; init; } public Guid? ParentExecutionId { get; init; } public int RowCount { get; init; } public string[] Channels { get; init; } = Array.Empty(); public string[] Statuses { get; init; } = Array.Empty(); public string? SourceSiteId { get; init; } public string? SourceInstanceId { get; init; } public DateTime? FirstOccurredAtUtc { get; init; } public DateTime? LastOccurredAtUtc { get; init; } } /// /// Pure helpers for the audit tree subcommand: builds the query string, /// calls GET /api/audit/tree, and renders the result as either an /// indented ASCII tree (table format) or raw JSON. Kept separate from the /// command wiring so each piece is unit-testable without standing up the /// command tree. /// public static class AuditTreeHelpers { private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true, }; private static readonly JsonSerializerOptions JsonWriteOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, }; /// /// Builds the query string for GET /api/audit/tree. /// /// The execution ID GUID. /// A relative path + query string ready to append to the base URL. public static string BuildUrl(Guid executionId) => $"api/audit/tree?executionId={executionId:D}"; /// /// Executes the tree lookup: GETs /api/audit/tree and renders the result /// in the requested format. Returns the process exit code (0 = success, /// 1 = error, 2 = authorization failure). /// /// The management HTTP client. /// The execution ID to look up. /// "table" (default) or "json". /// The output writer for results. /// A task that resolves to the process exit code. public static async Task RunTreeAsync( ManagementHttpClient client, Guid executionId, string format, TextWriter output) { var url = BuildUrl(executionId); var response = await client.SendGetAsync(url, TimeSpan.FromSeconds(30)); if (response.JsonData == null) { OutputFormatter.WriteError( response.Error ?? "Audit tree request failed.", response.ErrorCode ?? "ERROR"); return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1; } var nodes = ParseNodes(response.JsonData); if (format == "json") { WriteJson(nodes, output); } else { WriteTable(nodes, executionId, output); } output.Flush(); return 0; } /// /// Parses the JSON array from the server into an array of /// . /// /// The raw JSON response body. /// An array of deserialized tree nodes (empty on parse failure). internal static AuditTreeNodeDto[] ParseNodes(string json) { try { return JsonSerializer.Deserialize(json, JsonReadOptions) ?? Array.Empty(); } catch (JsonException) { return Array.Empty(); } } /// /// Renders the nodes as pretty-printed JSON to . /// internal static void WriteJson(AuditTreeNodeDto[] nodes, TextWriter output) { output.WriteLine(JsonSerializer.Serialize(nodes, JsonWriteOptions)); } /// /// Renders the nodes as an indented ASCII tree. The root node (null /// ParentExecutionId) is printed first; each child is indented /// two spaces per depth level. The queried/entry-point node is marked /// with [*]. /// internal static void WriteTable( AuditTreeNodeDto[] nodes, Guid queriedExecutionId, TextWriter output) { if (nodes.Length == 0) { output.WriteLine("(no execution tree found)"); return; } // Build a parent → children lookup (keyed by non-null parent Guid). // Nodes whose ParentExecutionId is null are roots and are not placed in // the lookup; they are identified separately below. var childrenOf = new Dictionary>(); foreach (var node in nodes) { if (node.ParentExecutionId is { } parentId) { if (!childrenOf.ContainsKey(parentId)) childrenOf[parentId] = new List(); childrenOf[parentId].Add(node); } } // Identify roots: nodes whose ParentExecutionId is null, or whose parent // is not present in the node set (stub-root case). var nodeIds = new HashSet(nodes.Select(n => n.ExecutionId)); var roots = nodes .Where(n => n.ParentExecutionId == null || !nodeIds.Contains(n.ParentExecutionId.Value)) .ToList(); // Render depth-first. var sb = new StringBuilder(); foreach (var root in roots) { RenderNode(root, depth: 0, childrenOf, queriedExecutionId, sb); } output.Write(sb.ToString()); } private static void RenderNode( AuditTreeNodeDto node, int depth, Dictionary> childrenOf, Guid queriedExecutionId, StringBuilder sb) { var indent = new string(' ', depth * 2); var marker = node.ExecutionId == queriedExecutionId ? " [*]" : string.Empty; var channels = node.Channels.Length > 0 ? string.Join(",", node.Channels) : "-"; var statuses = node.Statuses.Length > 0 ? string.Join(",", node.Statuses) : "-"; var site = node.SourceSiteId ?? "-"; var instance = node.SourceInstanceId ?? "-"; var first = node.FirstOccurredAtUtc.HasValue ? node.FirstOccurredAtUtc.Value.ToString("yyyy-MM-ddTHH:mm:ssZ") : "-"; sb.AppendLine( $"{indent}{node.ExecutionId:D}{marker} rows={node.RowCount} channels=[{channels}] statuses=[{statuses}] site={site} instance={instance} first={first}"); if (childrenOf.TryGetValue(node.ExecutionId, out var children)) { foreach (var child in children) { RenderNode(child, depth + 1, childrenOf, queriedExecutionId, sb); } } } }