5a878b78d4
Add missing <summary>/<param>/<returns>/<typeparam> tags and switch interface implementations to <inheritdoc/> across 106 files; strip project bookkeeping identifiers (Task NN, #05-TNN, PLAN-04, StoreAndForward-0NN) from shipped code comments while preserving the descriptive rationale. Comment-only: zero code-logic lines changed; solution builds 0/0. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
249 lines
8.8 KiB
C#
249 lines
8.8 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|
|
|
/// <summary>
|
|
/// Arguments for an <c>audit tree</c> invocation.
|
|
/// </summary>
|
|
public sealed class AuditTreeArgs
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public string ExecutionId { get; set; } = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents one execution node as returned by <c>GET /api/audit/tree</c>.
|
|
/// Property names match the server's camelCase JSON serialisation of
|
|
/// <c>ExecutionTreeNode</c>.
|
|
/// </summary>
|
|
internal sealed class AuditTreeNodeDto
|
|
{
|
|
/// <summary>
|
|
/// The execution ID (GUID) identifying this node in the tree.
|
|
/// </summary>
|
|
public Guid ExecutionId { get; init; }
|
|
|
|
/// <summary>
|
|
/// The execution ID of the spawning run, or <c>null</c> for a root node.
|
|
/// </summary>
|
|
public Guid? ParentExecutionId { get; init; }
|
|
|
|
/// <summary>
|
|
/// The number of audit log rows contributed by this execution.
|
|
/// </summary>
|
|
public int RowCount { get; init; }
|
|
|
|
/// <summary>
|
|
/// The set of audit channels touched by this execution.
|
|
/// </summary>
|
|
public string[] Channels { get; init; } = Array.Empty<string>();
|
|
|
|
/// <summary>
|
|
/// The set of distinct row statuses recorded for this execution.
|
|
/// </summary>
|
|
public string[] Statuses { get; init; } = Array.Empty<string>();
|
|
|
|
/// <summary>
|
|
/// The site identifier the execution originated from, or <c>null</c> if not site-scoped.
|
|
/// </summary>
|
|
public string? SourceSiteId { get; init; }
|
|
|
|
/// <summary>
|
|
/// The instance identifier the execution originated from, or <c>null</c> if not instance-scoped.
|
|
/// </summary>
|
|
public string? SourceInstanceId { get; init; }
|
|
|
|
/// <summary>
|
|
/// The timestamp (UTC) of the earliest audit row for this execution, if any.
|
|
/// </summary>
|
|
public DateTime? FirstOccurredAtUtc { get; init; }
|
|
|
|
/// <summary>
|
|
/// The timestamp (UTC) of the latest audit row for this execution, if any.
|
|
/// </summary>
|
|
public DateTime? LastOccurredAtUtc { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure helpers for the <c>audit tree</c> subcommand: builds the query string,
|
|
/// calls <c>GET /api/audit/tree</c>, 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.
|
|
/// </summary>
|
|
public static class AuditTreeHelpers
|
|
{
|
|
private static readonly JsonSerializerOptions JsonReadOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
private static readonly JsonSerializerOptions JsonWriteOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = true,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Builds the query string for <c>GET /api/audit/tree</c>.
|
|
/// </summary>
|
|
/// <param name="executionId">The execution ID GUID.</param>
|
|
/// <returns>A relative path + query string ready to append to the base URL.</returns>
|
|
public static string BuildUrl(Guid executionId)
|
|
=> $"api/audit/tree?executionId={executionId:D}";
|
|
|
|
/// <summary>
|
|
/// Executes the tree lookup: GETs <c>/api/audit/tree</c> and renders the result
|
|
/// in the requested format. Returns the process exit code (0 = success,
|
|
/// 1 = error, 2 = authorization failure).
|
|
/// </summary>
|
|
/// <param name="client">The management HTTP client.</param>
|
|
/// <param name="executionId">The execution ID to look up.</param>
|
|
/// <param name="format">"table" (default) or "json".</param>
|
|
/// <param name="output">The output writer for results.</param>
|
|
/// <returns>A task that resolves to the process exit code.</returns>
|
|
public static async Task<int> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the JSON array from the server into an array of
|
|
/// <see cref="AuditTreeNodeDto"/>.
|
|
/// </summary>
|
|
/// <param name="json">The raw JSON response body.</param>
|
|
/// <returns>An array of deserialized tree nodes (empty on parse failure).</returns>
|
|
internal static AuditTreeNodeDto[] ParseNodes(string json)
|
|
{
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<AuditTreeNodeDto[]>(json, JsonReadOptions)
|
|
?? Array.Empty<AuditTreeNodeDto>();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return Array.Empty<AuditTreeNodeDto>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the nodes as pretty-printed JSON to <paramref name="output"/>.
|
|
/// </summary>
|
|
/// <param name="nodes">The tree nodes to render.</param>
|
|
/// <param name="output">The output writer for results.</param>
|
|
internal static void WriteJson(AuditTreeNodeDto[] nodes, TextWriter output)
|
|
{
|
|
output.WriteLine(JsonSerializer.Serialize(nodes, JsonWriteOptions));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the nodes as an indented ASCII tree. The root node (null
|
|
/// <c>ParentExecutionId</c>) is printed first; each child is indented
|
|
/// two spaces per depth level. The queried/entry-point node is marked
|
|
/// with <c> [*]</c>.
|
|
/// </summary>
|
|
/// <param name="nodes">The tree nodes to render.</param>
|
|
/// <param name="queriedExecutionId">The execution ID originally queried, used to mark the entry-point node.</param>
|
|
/// <param name="output">The output writer for results.</param>
|
|
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<Guid, List<AuditTreeNodeDto>>();
|
|
foreach (var node in nodes)
|
|
{
|
|
if (node.ParentExecutionId is { } parentId)
|
|
{
|
|
if (!childrenOf.ContainsKey(parentId))
|
|
childrenOf[parentId] = new List<AuditTreeNodeDto>();
|
|
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<Guid>(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<Guid, List<AuditTreeNodeDto>> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|