diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor index ce941dea..1dbca632 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor @@ -3,6 +3,7 @@ @using System.Text.Json @using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging @inject NavigationManager Navigation +@inject IJSRuntime Js @* Flow Debugger step detail (design 2026-09-05 §4): tabs Input · Output · Attribute writes · Timing plus a "Where this ran" column with drill-ins. @@ -11,7 +12,9 @@ System / Connection / List + "Delivered by" facts. Follow-ups (plan 2026-09-06 D2/D5, Task 6): an "Open in …" deep link to the report page for a correlated outbound step, and Retry / Discard buttons on a Parked one when the owner - supplies the callbacks. *@ + supplies the callbacks. + Step logs (plan 2026-09-09 D8, Task 12): a Log tab after Output carrying the step's thrown exception + and the log lines folded onto it, level-filtered, with a hint to sibling steps of the same execution. *@
@@ -55,10 +58,98 @@ case "Output": @if (!string.IsNullOrEmpty(Step.ErrorMessage)) { -
@Step.ErrorMessage
+
+ @Step.ErrorMessage + @if (HasException) + { + + see exception + } +
}
@Pretty(Step.OutputJson)
break; + case "Log": + @if (HasException) + { +
+
@ExceptionTypeName
+
@ExceptionMessageText
+
@Step.ErrorDetail
+ +
+ } + @if (LogLineCount > 0) + { +
+
+ + +
+ @LogLineCount line@(LogLineCount == 1 ? "" : "s") on this step + @if (SiblingStepsWithLogs is { Count: > 0 } siblings) + { + + @for (var i = 0; i < siblings.Count; i++) + { + var other = siblings[i]; + @(i > 0 ? " · " : "") + @other.Logs!.Count more on + @if (OnSelectStep.HasDelegate) + { + + } + else + { + @other.Title + } + } + + } + +
+ + + + @foreach (var line in VisibleLogLines) + { + + + + + + + } + +
Time (UTC)LevelSourceMessage
@line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)@line.Level@line.Source + @line.Message + @if (!string.IsNullOrEmpty(line.ExceptionMessage)) + { +
+ @line.ExceptionMessage + @if (!string.IsNullOrEmpty(line.ExceptionDetail)) + { +
+ detail +
@line.ExceptionDetail
+
+ } +
+ } +
+ } + else if (!HasException) + { +
No log lines on this step.
+ } + break; case "Writes": @if (Step.Extra?.Writes is { Count: > 0 } writes) { @@ -187,8 +278,8 @@
@code { - private static readonly string[] Tabs = ["Input", "Output", "Writes", "Timing"]; - private static readonly string[] TabsWithLifecycle = ["Input", "Output", "Lifecycle", "Timing"]; + private static readonly string[] Tabs = ["Input", "Output", "Log", "Writes", "Timing"]; + private static readonly string[] TabsWithLifecycle = ["Input", "Output", "Log", "Lifecycle", "Timing"]; /// The selected step; null renders the placeholder. [Parameter] public FlowStep? Step { get; set; } @@ -211,8 +302,20 @@ /// True while the owner is relaying a Retry / Discard — disables both buttons. [Parameter] public bool ActionInProgress { get; set; } + /// + /// Select another step: the Log tab's cross-step hint names the sibling steps of the same execution + /// that carry their own lines. No delegate = the hint renders their titles as plain text. + /// + [Parameter] public EventCallback OnSelectStep { get; set; } + private string _tab = "Input"; + /// Log level filter: false = All, true = Warn + (hides debug and info). + private bool _warnOnly; + + /// Step the tab state belongs to — a different step re-picks the default tab and resets the filter. + private Guid? _tabStepId; + private bool IsOutbound => Step is { } s && FlowStepProjector.IsOutbound(s.Kind); /// @@ -234,19 +337,99 @@ private bool ShowActions => Step is { Outcome: FlowStepOutcome.Parked, CorrelationId: not null } && IsOutbound; - /// Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab. + /// + /// Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab; the Log tab + /// appears only for a step that actually has something to show there (D8). + /// private IEnumerable VisibleTabs - => Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs; + { + get + { + var tabs = Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs; + return HasLogTab ? tabs : tabs.Where(t => t != "Log"); + } + } - /// Kind-specific label for the Input tab: what the payload actually is. + /// Number of log lines folded onto this step. + private int LogLineCount => Step?.Logs?.Count ?? 0; + + /// True when the step carries a thrown exception's full detail. + private bool HasException => !string.IsNullOrWhiteSpace(Step?.ErrorDetail); + + private bool HasLogTab => LogLineCount > 0 || HasException; + + /// Kind-specific label for the Input tab, plus the Log tab's line count. private string TabLabel(string tab) - => tab != "Input" ? tab : Step?.Kind switch + { + if (tab == "Log") return LogLineCount > 0 ? $"Log ({LogLineCount})" : "Log"; + if (tab != "Input") return tab; + return Step?.Kind switch { FlowStepKind.ApiCall => "Request", FlowStepKind.DbCall => "SQL + parameters", FlowStepKind.Notify => "Subject + body", _ => "Input", }; + } + + /// First line of the exception detail — Namespace.Type: message for a .NET ToString(). + private string FirstDetailLine + => Step?.ErrorDetail is { } d ? d.Split('\n')[0].Trim('\r', ' ') : string.Empty; + + /// The exception's type, shown in full here (the node badge is the place that shortens it). + private string ExceptionTypeName + { + get + { + var line = FirstDetailLine; + var i = line.IndexOf(": ", StringComparison.Ordinal); + return i > 0 ? line[..i] : line; + } + } + + /// The exception's message; falls back to the step's own error summary if the detail is not the usual shape. + private string ExceptionMessageText + { + get + { + var line = FirstDetailLine; + var i = line.IndexOf(": ", StringComparison.Ordinal); + return i > 0 ? line[(i + 2)..] : (Step?.ErrorMessage ?? string.Empty); + } + } + + /// Lines shown under the current filter — Warn + hides debug and info, keeping anything unrecognised. + private IEnumerable VisibleLogLines + => (Step?.Logs ?? []).Where(l => !_warnOnly + || !(string.Equals(l.Level, "debug", StringComparison.OrdinalIgnoreCase) + || string.Equals(l.Level, "info", StringComparison.OrdinalIgnoreCase))); + + /// Other steps of the same execution that carry lines of their own (the cross-step hint, D8). + private IReadOnlyList SiblingStepsWithLogs + => Step is { ExecutionId: { } exec } s && Graph is not null + ? Graph.Steps.Where(o => o.ExecutionId == exec && o.EventId != s.EventId && o.Logs is { Count: > 0 }).ToList() + : []; + + private void ShowLogTab() => _tab = "Log"; + + /// Copies the whole exception detail / the shown lines to the clipboard. + private Task CopyLinesAsync() + => CopyAsync(string.Join('\n', VisibleLogLines.Select(l => + $"{l.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)} {l.Level} {l.Source} {l.Message}"))); + + private async Task CopyAsync(string? text) + { + if (string.IsNullOrEmpty(text)) return; + try + { + await Js.InvokeVoidAsync("navigator.clipboard.writeText", text); + } + catch (Exception) + { + // Copying is a convenience: the clipboard API is absent outside a secure context and the + // interop call throws on a disconnected / prerendering circuit. Never surface that to the operator. + } + } private string TargetLabel => Step?.Kind switch { @@ -264,7 +447,17 @@ protected override void OnParametersSet() { - if (!VisibleTabs.Contains(_tab)) _tab = "Input"; + if (Step?.EventId != _tabStepId) + { + // A different step: open on Log when it threw (D8), and start over on All. + _tabStepId = Step?.EventId; + _tab = HasException ? "Log" : "Input"; + _warnOnly = false; + } + else if (!VisibleTabs.Contains(_tab)) + { + _tab = "Input"; + } } private int AuditRowCount diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.css index d3a67aae..76449a91 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.css +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.css @@ -69,3 +69,57 @@ max-height: 18rem; overflow: auto; } + +/* Step detail Log tab (step-logs plan 2026-09-09 D8): the thrown exception, then the folded lines. */ + +::deep .fsd-exc { + border: 1px solid var(--bad, var(--bs-danger, #e03131)); + border-left-width: 4px; + border-radius: 4px; + background: var(--bad-bg, rgba(var(--bs-danger-rgb, 220, 53, 69), 0.08)); + padding: 8px 12px; + margin-bottom: 10px; +} + +::deep .fsd-exc-type { + font-family: var(--mono, var(--bs-font-monospace)); + font-weight: 600; + color: var(--bad, var(--bs-danger, #e03131)); +} + +::deep .fsd-exc-detail { + margin: 6px 0 0; + font-family: var(--mono, var(--bs-font-monospace)); + font-size: 0.75rem; + white-space: pre; + overflow-x: auto; + color: var(--ink-soft, var(--bs-secondary-color)); +} + +::deep .fsd-line-exc { + color: var(--ink-soft, var(--bs-secondary-color)); + margin-top: 2px; +} + +::deep .fsd-logctl { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + font-size: 0.8rem; +} + +::deep .fsd-lvl { + display: inline-block; + min-width: 46px; + text-align: center; + font-size: 0.66rem; + font-weight: 600; + letter-spacing: 0.04em; + padding: 1px 6px; + border-radius: 3px; +} +::deep .fsd-lvl-info { color: var(--ink-soft, var(--bs-secondary-color)); background: var(--rule, rgba(0, 0, 0, 0.06)); } +::deep .fsd-lvl-warn { color: var(--warn, var(--bs-warning, #e8920c)); background: var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); } +::deep .fsd-lvl-error { color: var(--bad, var(--bs-danger, #e03131)); background: var(--bad-bg, rgba(var(--bs-danger-rgb, 220, 53, 69), 0.12)); } +::deep .fsd-lvl-debug { color: var(--ink-faint, var(--bs-tertiary-color)); background: transparent; border: 1px dashed var(--rule-strong, var(--bs-border-color)); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs index 6a2b65f9..2d9155d3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs @@ -8,11 +8,46 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab, /// outbound "Where this ran" fields and kind-specific Input tab labels. Follow-ups plan (2026-09-06) /// Task 6: report deep links from a correlated outbound step and Retry/Discard on a parked one (D2/D5). +/// Step-logs plan (2026-09-09) Task 12: the Log tab — exception block, level-filtered lines and the +/// cross-step hint (design D8). /// public class FlowStepDetailTests : BunitContext { private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero); + public FlowStepDetailTests() + { + // The Log tab's Copy buttons call navigator.clipboard through IJSRuntime. + JSInterop.Mode = JSRuntimeMode.Loose; + } + + private const string ExceptionDetail = + "System.InvalidOperationException: Recipe 42 rejected by Galaxy (code 7)\n" + + " at Submission#0.<>d__0.MoveNext() in ProcessRecipeDownload:line 41\n" + + " at ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRunLauncher.RunScriptAsync()"; + + /// A site script-run step, optionally carrying folded log lines and a thrown exception's detail. + private static FlowStep ScriptStep( + FlowStepOutcome outcome, + string title, + Guid executionId, + IReadOnlyList? logs = null, + string? errorDetail = null) + => new(Guid.NewGuid(), executionId, null, FlowStepKind.ScriptRun, FlowLane.Site, title, + "DelmiaSim01.DelmiaReceiver \u00b7 call", T0, 1731, outcome, "{\"RecipeId\":42}", "{\"ok\":false}", + outcome == FlowStepOutcome.Failed ? "Recipe 42 rejected by Galaxy (code 7)" : null, + "site-a", "node-a", "DelmiaSim01", title, null, null, null, errorDetail, logs); + + private static FlowLogLine Line( + string level, + string message, + string source = "script", + int seq = 0, + string? exceptionMessage = null, + string? exceptionDetail = null) + => new(Guid.NewGuid(), null, T0.AddMilliseconds(seq), level, source, message, + exceptionMessage, exceptionDetail, null, seq); + private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList? lifecycle = null, Guid? correlationId = null) => new(Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, target, "Delivered · cached", T0, 4100, outcome, "{\"id\":1}", "{\"ok\":true}", outcome == FlowStepOutcome.Failed ? "boom" : null, "site-a", "node-a", "M1", target, null, lifecycle, correlationId); @@ -182,4 +217,149 @@ public class FlowStepDetailTests : BunitContext Assert.Empty(cut.FindAll("[data-test=flow-step-retry]")); Assert.Empty(cut.FindAll("[data-test=flow-step-discard]")); } + + [Fact] + public void NoLinesAndNoDetail_HidesTheLogTab() + { + var cut = Render(p => p.Add(x => x.Step, ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid()))); + Assert.Empty(cut.FindAll("[data-test=flow-step-tab-log]")); + } + + [Fact] + public void LogTab_CountsItsLines() + { + var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(), + [ + Line("info", "Recipe 42 requested", seq: 1), + Line("debug", "Writing header attributes", seq: 2), + Line("warn", "ResultCode 7 is outside the accepted set", seq: 3), + ]); + var cut = Render(p => p.Add(x => x.Step, step)); + + Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim()); + } + + [Fact] + public void FailedStepWithDetail_OpensOnLog_AndRendersTheExceptionBlock() + { + var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(), + [Line("error", "Rejecting recipe 42 (code 7)", seq: 1)], ExceptionDetail); + var cut = Render(p => p.Add(x => x.Step, step)); + + Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName); + var exc = cut.Find("[data-test=flow-step-exception]"); + Assert.Equal("System.InvalidOperationException", exc.QuerySelector(".fsd-exc-type")!.TextContent.Trim()); + Assert.Equal("Recipe 42 rejected by Galaxy (code 7)", exc.QuerySelector(".fsd-exc-message")!.TextContent.Trim()); + Assert.Contains("ScriptRunLauncher.RunScriptAsync", exc.QuerySelector("pre")!.TextContent); + Assert.NotNull(cut.Find("[data-test=flow-step-exception-copy]")); + } + + [Fact] + public void OkStepWithLines_OpensOnInput() + { + var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(), [Line("info", "hello", seq: 1)]); + var cut = Render(p => p.Add(x => x.Step, step)); + + Assert.Contains("active", cut.Find("[data-test=flow-step-tab-input]").ClassName); + Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-log]").ClassName); + Assert.Empty(cut.FindAll("[data-test=flow-step-exception]")); + } + + [Fact] + public void WarnFilter_LeavesOnlyWarnAndErrorRows() + { + var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(), + [ + Line("info", "Recipe 42 requested", seq: 1), + Line("debug", "Writing header attributes", seq: 2), + Line("warn", "ResultCode 7 is outside the accepted set", seq: 3), + Line("error", "Rejecting recipe 42", seq: 4), + ]); + var cut = Render(p => p.Add(x => x.Step, step)); + cut.Find("[data-test=flow-step-tab-log]").Click(); + + Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count); + + cut.Find("[data-test=flow-step-log-filter-warn]").Click(); + var rows = cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]"); + Assert.Equal(2, rows.Count); + Assert.Equal("warn", rows[0].GetAttribute("data-level")); + Assert.Equal("error", rows[1].GetAttribute("data-level")); + + cut.Find("[data-test=flow-step-log-filter-all]").Click(); + Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count); + } + + [Fact] + public void Hint_NamesAnotherStepOfTheSameExecution_AndSelectsIt() + { + var exec = Guid.NewGuid(); + var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]); + var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "acked Uncertain", seq: 2)]); + var elsewhere = ScriptStep(FlowStepOutcome.Ok, "Another execution", Guid.NewGuid(), [Line("info", "c", seq: 3)]); + var graph = new FlowGraph([selected, other, elsewhere], [], false, false); + FlowStep? picked = null; + + var cut = Render(p => p + .Add(x => x.Step, selected) + .Add(x => x.Graph, graph) + .Add(x => x.OnSelectStep, s => picked = s)); + cut.Find("[data-test=flow-step-tab-log]").Click(); + + var hint = cut.Find("[data-test=flow-step-log-hint]"); + Assert.Contains("1 more on", hint.TextContent); + Assert.Contains("Write RecipeId, RecipeName", hint.TextContent); + Assert.DoesNotContain("Another execution", hint.TextContent); + + hint.QuerySelector("button")!.Click(); + Assert.Same(other, picked); + } + + [Fact] + public void Hint_WithoutASelectCallback_IsPlainText() + { + var exec = Guid.NewGuid(); + var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]); + var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "b", seq: 2)]); + var graph = new FlowGraph([selected, other], [], false, false); + + var cut = Render(p => p.Add(x => x.Step, selected).Add(x => x.Graph, graph)); + cut.Find("[data-test=flow-step-tab-log]").Click(); + + var hint = cut.Find("[data-test=flow-step-log-hint]"); + Assert.Contains("Write RecipeId, RecipeName", hint.TextContent); + Assert.Null(hint.QuerySelector("button")); + } + + [Fact] + public void OutputAlert_SeeExceptionLink_SwitchesToTheLogTab() + { + var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(), + [Line("error", "Rejecting recipe 42", seq: 1)], ExceptionDetail); + var cut = Render(p => p.Add(x => x.Step, step)); + + cut.Find("[data-test=flow-step-tab-output]").Click(); + Assert.Contains("Recipe 42 rejected", cut.Find("[data-test=flow-step-error]").TextContent); + + cut.Find("[data-test=flow-step-error-see-log]").Click(); + Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName); + Assert.NotNull(cut.Find("[data-test=flow-step-exception]")); + } + + [Fact] + public void RuntimeSourcedLine_ShowsItsSource_AndAnAttachedException() + { + var step = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId", Guid.NewGuid(), + [ + Line("warn", "acked with quality Uncertain", source: "runtime", seq: 1, + exceptionMessage: "Tag write was not confirmed", exceptionDetail: "System.TimeoutException: Tag write was not confirmed\n at Dcl"), + ]); + var cut = Render(p => p.Add(x => x.Step, step)); + cut.Find("[data-test=flow-step-tab-log]").Click(); + + var row = cut.Find("[data-test=flow-step-log-lines] tr[data-level=warn]"); + Assert.Contains("runtime", row.TextContent); + Assert.Contains("Tag write was not confirmed", row.QuerySelector(".fsd-line-exc")!.TextContent); + Assert.Contains("System.TimeoutException", row.QuerySelector("details pre")!.TextContent); + } }