using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// /// Compact table projection for the flow-debug commands. /// /// /// The handler returns a FlowDebugRunResult — { rootExecutionId, completed, /// error, errorCode, events: [ … ], truncated, … } whose events are canonical /// audit events (each carrying its domain fields inside a detailsJson string). Handed /// to the generic renderer unprojected, the object takes the Property/Value branch and the /// whole event array lands in one cell as raw JSON. This projects the events through the same /// the Flow Debugger page uses and prints one row per STEP — /// occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs — /// in the order the page draws them, so a terminal user can read the swimlane top-to-bottom. An /// outbound operation's rows (a cached call's Submit → Forwarded → Attempted → Resolve, a /// notification's Send → Deliver) fold into ONE row whose lifecycle column counts them /// and whose status is the latest row's (outbound-calls plan 2026-09-06, D3). logs /// counts the step's attached ScriptLog lines (step-logs design 2026-09-09); passing /// showLogs additionally prints those lines, and a failed step's exception detail, as /// raw indented lines immediately under that step's row — 's table /// renderer special-cases a single-property { "note": … } row to print it that way /// instead of padding it into a table cell. A trailing note row (the same special case) /// reports a failed / incomplete run (with the error code verbatim, e.g. STANDBY_NODE) /// or a truncated event list — and, separately, an inline log-line cap (logsTruncated). /// /// /// JSON output is never touched — --format json gets the full raw result. /// internal static class FlowDebugTableProjection { /// /// Projects a FlowDebugRunResult body to the row array the table renderer /// consumes. Returns the input unchanged when it is not JSON or not that shape, so the /// renderer's own fallbacks still apply. /// /// The raw success JSON body from the management API. /// /// When true, prints each step's ScriptLog lines and (on a failed step) the first /// three lines of its exception detail as additional indented note rows immediately /// following that step's row. The logs /// column (a count of attached log lines) is always present, independent of this flag. /// /// Compact JSON suitable for table rendering. internal static string ProjectEvents(string json, bool showLogs = false) { JsonDocument doc; try { doc = JsonDocument.Parse(json); } catch (JsonException) { return json; } using (doc) { var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object || !TryGetPropertyCI(root, "events", out var events) || events.ValueKind != JsonValueKind.Array) { return json; } var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True; // Step logs (design 2026-09-09 D7): the inline copy sheds low-priority ScriptLog rows // BEFORE it sheds step rows, so `logsTruncated` is a separate signal from `truncated` // and needs its own note — the audit rows are complete, so a replay shows every line. var logsTruncated = TryGetPropertyCI(root, "logsTruncated", out var lt) && lt.ValueKind == JsonValueKind.True; var rows = ProjectSteps(events, truncated, showLogs); if (rows == null) { // The event array did not deserialize as canonical audit events (a future wire // shape, or a proxy body) — fall back to one row per raw event so nothing is hidden. rows = new JsonArray(); foreach (var evt in events.EnumerateArray()) rows.Add(ProjectEvent(evt)); } var completed = TryGetPropertyCI(root, "completed", out var c) && c.ValueKind == JsonValueKind.True; var errorCode = StrValue(root, "errorCode"); var error = StrValue(root, "error"); var notes = new List(); if (!completed || errorCode != null || error != null) { notes.Add(errorCode != null ? $"{errorCode}: {error ?? "run did not complete"}" : error ?? "run did not complete"); } if (truncated) notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id )"); // The page shows this; the CLI silently dropped it. if (logsTruncated) notes.Add("inline log lines capped — replay pulls the rest"); if (notes.Count > 0) rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) }); return rows.ToJsonString(); } } private static readonly JsonSerializerOptions EventOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() }, }; /// /// Builds the flow graph from the canonical events and projects one row per step. Returns /// null when the array is not a list of canonical audit events. /// private static JsonArray? ProjectSteps(JsonElement events, bool truncated, bool showLogs) { List? list; try { list = JsonSerializer.Deserialize>(events.GetRawText(), EventOptions); } catch (JsonException) { return null; } if (list == null || list.Any(e => e == null || e.EventId == Guid.Empty)) return null; // Status per event: the graph's outcome is a coarser projection; the table keeps the // audit status word (Delivered / Submitted / Parked …) the operator sees elsewhere. var statusById = new Dictionary(); foreach (var e in list) statusById[e.EventId] = AuditDetailsCodec.Deserialize(e.DetailsJson).Status; var graph = FlowGraphBuilder.Build(list, truncated); var rows = new JsonArray(); foreach (var step in graph.Steps) { var lifecycle = step.Lifecycle; var status = lifecycle is { Count: > 0 } ? lifecycle[^1].Status : statusById.TryGetValue(step.EventId, out var st) ? st : step.Outcome.ToString(); var target = step.Title; if (step.Lane != FlowLane.External && step.Instance != null && !target.StartsWith(step.Instance, StringComparison.Ordinal)) target = $"{step.Instance}.{target}"; rows.Add(new JsonObject { ["occurred"] = step.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture), ["kind"] = step.Kind.ToString(), ["lane"] = step.Lane.ToString(), ["target"] = target, ["durationMs"] = step.DurationMs, ["status"] = status, ["lifecycle"] = lifecycle is { Count: > 0 } ? $"{lifecycle.Count} {(lifecycle.Count == 1 ? "row" : "rows")}" : null, ["correlation"] = step.CorrelationId?.ToString("D"), ["error"] = step.ErrorMessage, ["logs"] = step.Logs?.Count ?? 0, }); if (!showLogs) continue; if (step.Logs is { Count: > 0 } logs) { foreach (var line in logs) rows.Add(new JsonObject { ["note"] = JsonValue.Create(FormatLogLine(line)) }); } if (!string.IsNullOrWhiteSpace(step.ErrorDetail)) { foreach (var line in FormatErrorDetailLines(step.ErrorDetail)) rows.Add(new JsonObject { ["note"] = JsonValue.Create(line) }); } } return rows; } /// Formats one as HH:mm:ss.fff LEVEL source message (UTC, level upper-cased and padded to 5 chars). private static string FormatLogLine(FlowLogLine line) { var time = line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); var level = (line.Level ?? "info").ToUpperInvariant().PadRight(5); return $" {time} {level} {line.Source} {line.Message}"; } /// /// Formats the first three lines of an exception's ToString() detail — the first /// prefixed exception: , the rest indented four spaces. /// private static IEnumerable FormatErrorDetailLines(string errorDetail) { var lines = errorDetail.Replace("\r\n", "\n").Split('\n'); for (var i = 0; i < Math.Min(3, lines.Length); i++) yield return i == 0 ? $" exception: {lines[i]}" : $" {lines[i]}"; } /// Projects one canonical audit event (camelCase JSON) to its compact row (fallback path). private static JsonNode ProjectEvent(JsonElement evt) { if (evt.ValueKind != JsonValueKind.Object) return JsonValue.Create(evt.ToString())!; var details = AuditDetailsCodec.Deserialize(StrValue(evt, "detailsJson")); var kind = FlowStepProjector.ResolveKind(details.Channel, details.Kind); var lane = FlowStepProjector.ResolveLane(kind, details.SourceSiteId); string? occurred = null; if (TryGetPropertyCI(evt, "occurredAtUtc", out var o) && o.ValueKind == JsonValueKind.String && o.TryGetDateTimeOffset(out var when)) { occurred = when.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); } var target = StrValue(evt, "target"); var instance = details.SourceInstanceId; var shownTarget = target ?? details.SourceScript; if (instance != null && shownTarget != null && !shownTarget.StartsWith(instance, StringComparison.Ordinal)) shownTarget = $"{instance}.{shownTarget}"; return new JsonObject { ["occurred"] = occurred, ["kind"] = kind.ToString(), ["lane"] = lane.ToString(), ["target"] = shownTarget ?? instance, ["durationMs"] = details.DurationMs, ["status"] = details.Status, ["error"] = details.ErrorMessage, }; } 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 string? StrValue(JsonElement obj, string name) => TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; }