0569c5ff23
Add GET /api/audit/tree endpoint that accepts executionId query param, authenticates via HTTP Basic + LDAP (OperationalAudit permission), calls IAuditLogRepository.GetExecutionTreeAsync, and returns a JSON array of ExecutionTreeNode. Returns 400 for missing/invalid GUID, 401/403 as normal. Add `scadabridge audit tree --execution-id <guid> [--format table|json]` CLI subcommand in AuditCommands.Build(). Adds AuditTreeHelpers with: - BuildUrl: constructs the relative URL + query string - RunTreeAsync: calls the endpoint, dispatches to table or JSON renderer - WriteTable: indented ASCII tree (root → children, [*] marks queried node) - WriteJson: pretty-printed JSON array pass-through Tests: 7 new ManagementService endpoint tests (valid id, empty, 400, 401, 403, Viewer allowed, wrong role), 18 new CLI tests (parse, render, HTTP error codes, JSON output, multi-level indentation, queried-node marker).
209 lines
7.4 KiB
C#
209 lines
7.4 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
|
|
{
|
|
public Guid ExecutionId { get; init; }
|
|
public Guid? ParentExecutionId { get; init; }
|
|
public int RowCount { get; init; }
|
|
public string[] Channels { get; init; } = Array.Empty<string>();
|
|
public string[] Statuses { get; init; } = Array.Empty<string>();
|
|
public string? SourceSiteId { get; init; }
|
|
public string? SourceInstanceId { get; init; }
|
|
public DateTime? FirstOccurredAtUtc { get; init; }
|
|
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>
|
|
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>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|