diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor index cd207e24..19b41fab 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor @@ -70,6 +70,7 @@ @foreach (var n in _layout.Nodes) { var node = n; + var logs = LogCounts(node.Step); @Duration(node)
@WrapHints(node.DisplayTitle)
- @if (HasPill(node)) - { - @* Outbound kinds: the folded outcome as a pill, then the call mode (node anatomy: "● Ok · sync"). *@ -
- @PillText(node.Step.Outcome) - · @OutboundMode(node.Kind, node.Subtitle) -
- } - else - { -
@node.DisplaySubtitle
- } + @* Footer row: the pill-or-subtitle on the left, the log-count badge on the right + (step-logs design 2026-09-09 D8). A node with no lines shows no badge at all. *@ +
+ @if (HasPill(node)) + { + @* Outbound kinds: the folded outcome as a pill, then the call mode (node anatomy: "● Ok · sync"). *@ +
+ @PillText(node.Step.Outcome) + · @OutboundMode(node.Kind, node.Subtitle) +
+ } + else + { +
@node.DisplaySubtitle
+ } + @if (logs.Errors + logs.Warnings + logs.Info > 0) + { + + @if (logs.Errors > 0) + { + ✕ @logs.Errors + } + @if (logs.Warnings > 0) + { + ! @logs.Warnings + } + @if (logs.Info > 0) + { + ≡ @logs.Info + } + + } +
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs index d3c6e561..3b2d1ceb 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs @@ -40,8 +40,14 @@ public sealed record FlowLayoutNode( /// Title with removed — the discriminating tail of a dotted path. public string DisplayTitle => FlowGraphView.StripPrefix(Title, LanePrefix) ?? Title; - /// Subtitle with removed from each comma-separated item. - public string? DisplaySubtitle => FlowGraphView.StripPrefix(Subtitle, LanePrefix); + /// + /// Subtitle with removed from each comma-separated item — except on a + /// failed/timed-out real step, where the exception's type and message take the line instead + /// (step-logs design 2026-09-09 D8). A lane prefix is a dotted attribute head, never part of an + /// exception line, so the exception text is shown verbatim. + /// + public string? DisplaySubtitle => (Step != null ? FlowGraphView.ExceptionSubtitle(Step) : null) + ?? FlowGraphView.StripPrefix(Subtitle, LanePrefix); /// Full, unclipped text for the hover tooltip. public string FullText => string.IsNullOrEmpty(Subtitle) ? Title : Title + " — " + Subtitle; @@ -231,6 +237,74 @@ public partial class FlowGraphView : ComponentBase return detail.Length == 0 ? "sync" : detail + " · sync"; } + /// + /// Log lines on a step folded to per-severity counts for the footer badge (step-logs design + /// 2026-09-09 D8). error and warn are counted by name (ordinal-ignore-case); + /// everything else — info, debug, an unknown level — counts as info, so the badge + /// never silently drops a line. + /// + internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s) + { + if (s?.Logs is not { Count: > 0 } lines) return (0, 0, 0); + var errors = 0; + var warnings = 0; + var info = 0; + foreach (var line in lines) + { + if (string.Equals(line.Level, "error", StringComparison.OrdinalIgnoreCase)) errors++; + else if (string.Equals(line.Level, "warn", StringComparison.OrdinalIgnoreCase)) warnings++; + else info++; + } + + return (errors, warnings, info); + } + + /// Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info"). + internal static string LogTitle((int Errors, int Warnings, int Info) counts) + { + var (errors, warnings, info) = counts; + var parts = new List(3); + if (errors > 0) parts.Add($"{errors} error{(errors == 1 ? "" : "s")}"); + if (warnings > 0) parts.Add($"{warnings} warning{(warnings == 1 ? "" : "s")}"); + if (info > 0) parts.Add($"{info} info"); + return string.Join(", ", parts); + } + + /// + /// The subtitle a failed or timed-out step shows instead of its own: {Type}: {message}, where + /// the type is the exception's name without its namespace, parsed from the first line of the captured + /// (a .NET ToString() opens with Namespace.Type: message). + /// A wrapper's first line chains its inner exceptions with " ---> "; the LAST segment is taken, + /// since the innermost exception is the one the script author has to act on. The namespace is stripped + /// only from the head of the type name — any generic arity/argument suffix (`1[System.String]) + /// is kept verbatim. Falls back to the line itself when it carries no ": ", and to + /// when there is no detail. Null for every non-failed step. + /// + internal static string? ExceptionSubtitle(FlowStep s) + { + if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null; + if (string.IsNullOrWhiteSpace(s.ErrorDetail)) return s.ErrorMessage; + + var first = s.ErrorDetail.Split('\n', 2)[0].TrimEnd('\r').Trim(); + // A wrapper (AggregateException, a rethrow) chains inner exceptions on the same line — the last is innermost. + var inner = first.LastIndexOf(" ---> ", StringComparison.Ordinal); + if (inner >= 0) first = first[(inner + " ---> ".Length)..].Trim(); + if (first.Length == 0) return s.ErrorMessage; + + var split = first.IndexOf(": ", StringComparison.Ordinal); + if (split < 0) return first; + + var type = first[..split]; + var message = first[(split + 2)..]; + // Shorten only the leading namespace: everything from a generic marker on is part of the name. + var generic = type.IndexOfAny(['`', '[']); + var head = generic < 0 ? type : type[..generic]; + var dot = head.LastIndexOf('.'); + if (dot >= 0 && dot < head.Length - 1) + type = head[(dot + 1)..] + (generic < 0 ? string.Empty : type[generic..]); + return type + ": " + message; + } + private static string Eyebrow(FlowLayoutNode n) => n.Kind switch { FlowStepKind.InboundRequest => "REQUEST", diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css index 5075c9dd..78b5f65f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css @@ -74,13 +74,23 @@ -webkit-box-orient: vertical; overflow: hidden; } -.fg-subtitle { color: var(--ink-soft); font-size: 0.66rem; margin-top: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* Footer row: the pill-or-subtitle, then the log-count badge (step-logs design 2026-09-09 D8). + The foot owns the push to the bottom of the body; neither child does. */ +.fg-foot { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 4px; min-width: 0; } +.fg-subtitle { color: var(--ink-soft); font-size: 0.66rem; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fg-node.failed .fg-subtitle, +.fg-node.timed-out .fg-subtitle { color: var(--bad, var(--bs-danger, #e03131)); font-family: var(--mono); } +.fg-logs { display: inline-flex; gap: 3px; flex-shrink: 0; font-family: var(--mono); font-size: 0.6rem; font-variant-numeric: tabular-nums; } +.fg-logs i { font-style: normal; padding: 0 4px; border-radius: 3px; line-height: 1.35; } +.fg-logs i.e { color: var(--bad, var(--bs-danger, #e03131)); background: var(--bad-bg, rgba(var(--bs-danger-rgb, 220, 53, 69), 0.12)); } +.fg-logs i.w { color: var(--warn, var(--bs-warning, #e8920c)); background: var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); } +.fg-logs i.i { color: var(--ink-faint); background: var(--rule); } .fg-node.failed .fg-eyebrow, .fg-node.timed-out .fg-eyebrow { color: var(--bs-danger, #c0392b); } /* Outbound nodes (API call / database / notify): the folded outcome as a status pill, then the call mode. Kit status tokens first (dark mode follows via site.css), Bootstrap's as fallback. */ -.fg-status { margin-top: auto; display: flex; align-items: center; gap: 4px; min-width: 0; } +.fg-status { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; } .fg-pill { display: inline-flex; align-items: center; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs index cd4370b0..539fa18a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs @@ -566,4 +566,153 @@ public class FlowGraphViewTests : BunitContext var edge = Assert.Single(layout.Edges); Assert.True(edge.Async); } + + // ── Step logs + exception subtitle (step-logs plan 2026-09-09, D8) ── + + private static FlowLogLine Line(string level, string message = "m", int seq = 0) + => new(Guid.NewGuid(), Root, T0.AddMilliseconds(seq), level, "script", message, null, null, null, seq); + + /// A one-node Site-lane graph whose single step carries the given logs / error shape. + private static FlowGraph LogGraph( + FlowStepOutcome outcome = FlowStepOutcome.Ok, + string? errorMessage = null, + string? errorDetail = null, + IReadOnlyList? logs = null) + { + var step = new FlowStep(Guid.NewGuid(), Root, null, FlowStepKind.ScriptRun, FlowLane.Site, "DoWork", "trigger: manual", + T0, 5, outcome, null, null, errorMessage, "site-a", "node-a", "M1", "DoWork", null, null, null, errorDetail, logs); + return new FlowGraph([step], [], false, false); + } + + [Fact] + public void Render_StepWithNoLogLines_HasNoLogBadge() + { + var cut = Render(p => p.Add(c => c.Graph, LogGraph())); + + Assert.Empty(cut.FindAll("[data-test='flow-node-logs']")); + // The footer still exists and holds the subtitle. + Assert.NotNull(cut.Find("g[data-test='flow-node'] .fg-foot [data-test='flow-node-subtitle']")); + } + + [Fact] + public void Render_StepWithMixedLevels_ShowsCountsHighestSeverityFirst_DebugCountedAsInfo() + { + var logs = new List + { + Line("error"), Line("warn"), Line("WARN"), Line("info"), Line("info"), Line("info"), Line("debug"), + }; + + var cut = Render(p => p.Add(c => c.Graph, LogGraph(logs: logs))); + + var badge = cut.Find("[data-test='flow-node-logs']"); + Assert.Equal(["✕ 1", "! 2", "≡ 4"], badge.QuerySelectorAll("i").Select(i => i.TextContent.Trim())); + Assert.Equal(["e", "w", "i"], badge.QuerySelectorAll("i").Select(i => i.GetAttribute("class"))); + Assert.Equal("1 error, 2 warnings, 4 info", badge.GetAttribute("title")); + } + + [Fact] + public void Render_StepWithOnlyInfoLines_ShowsASingleFaintCount() + { + var cut = Render(p => p.Add(c => c.Graph, LogGraph(logs: [Line("info"), Line("debug")]))); + + var badge = cut.Find("[data-test='flow-node-logs']"); + var item = Assert.Single(badge.QuerySelectorAll("i")); + Assert.Equal("≡ 2", item.TextContent.Trim()); + Assert.Equal("i", item.GetAttribute("class")); + Assert.Equal("2 info", badge.GetAttribute("title")); + } + + [Fact] + public void Render_FailedStepWithErrorDetail_SubtitleIsTheExceptionTypeAndMessage() + { + var graph = LogGraph(FlowStepOutcome.Failed, errorMessage: "Recipe 42 rejected", + errorDetail: "System.InvalidOperationException: Recipe 42 rejected\n at X"); + + var cut = Render(p => p.Add(c => c.Graph, graph)); + + Assert.Equal("InvalidOperationException: Recipe 42 rejected", + cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim()); + } + + [Fact] + public void Render_FailedStepWithoutDetail_FallsBackToTheErrorMessage() + { + var cut = Render(p => p.Add(c => c.Graph, LogGraph(FlowStepOutcome.Failed, errorMessage: "timed out talking to MesDb"))); + + Assert.Equal("timed out talking to MesDb", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim()); + } + + [Fact] + public void Render_OkStepWithAnErrorDetail_KeepsItsNormalSubtitle() + { + var cut = Render(p => p.Add(c => c.Graph, LogGraph(errorDetail: "System.InvalidOperationException: stale"))); + + Assert.Equal("trigger: manual", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim()); + } + + [Fact] + public void LogCounts_FoldsLevelsCaseInsensitively_AndTreatsEverythingElseAsInfo() + { + Assert.Equal((0, 0, 0), FlowGraphView.LogCounts(null)); + Assert.Equal((0, 0, 0), FlowGraphView.LogCounts(LogGraph().Steps[0])); + + var step = LogGraph(logs: [Line("Error"), Line("error"), Line("Warn"), Line("info"), Line("debug"), Line("trace")]).Steps[0]; + Assert.Equal((2, 1, 3), FlowGraphView.LogCounts(step)); + } + + [Theory] + [InlineData(FlowStepOutcome.Failed, "System.InvalidOperationException: Recipe 42 rejected\n at X", "boom", "InvalidOperationException: Recipe 42 rejected")] + [InlineData(FlowStepOutcome.TimedOut, "System.TimeoutException: took too long", "boom", "TimeoutException: took too long")] + [InlineData(FlowStepOutcome.Failed, "SomethingWentWrong", "boom", "SomethingWentWrong")] + [InlineData(FlowStepOutcome.Failed, " ", "boom", "boom")] + [InlineData(FlowStepOutcome.Failed, null, "boom", "boom")] + [InlineData(FlowStepOutcome.Ok, "System.InvalidOperationException: nope", "boom", null)] + // A wrapper chains its inner exceptions on the first line — the innermost is the one to act on. + [InlineData(FlowStepOutcome.Failed, + "System.AggregateException: One or more errors occurred. (Recipe 42 rejected) ---> System.InvalidOperationException: Recipe 42 rejected\n at X", + "boom", "InvalidOperationException: Recipe 42 rejected")] + [InlineData(FlowStepOutcome.Failed, + "A.Outer: outer ---> B.Middle: middle ---> C.Innermost: the real one", + "boom", "Innermost: the real one")] + // The namespace is stripped only from the head of the name; a generic suffix stays verbatim. + [InlineData(FlowStepOutcome.Failed, "MyApp.RecipeException`1[System.String]: msg", "boom", "RecipeException`1[System.String]: msg")] + [InlineData(FlowStepOutcome.Failed, "RecipeException`1[System.String]: msg", "boom", "RecipeException`1[System.String]: msg")] + public void ExceptionSubtitle_ParsesTheFirstLineOfTheDetail_OnlyForAFailedStep( + FlowStepOutcome outcome, string? detail, string? message, string? expected) + { + var step = LogGraph(outcome, errorMessage: message, errorDetail: detail).Steps[0]; + + Assert.Equal(expected, FlowGraphView.ExceptionSubtitle(step)); + } + + [Fact] + public void LogTitle_NamesOnlyTheNonZeroLevels_WithSingularAndPlural() + { + Assert.Equal("1 error", FlowGraphView.LogTitle((1, 0, 0))); + Assert.Equal("2 errors, 1 warning", FlowGraphView.LogTitle((2, 1, 0))); + Assert.Equal("1 warning, 2 info", FlowGraphView.LogTitle((0, 1, 2))); + Assert.Equal("3 info", FlowGraphView.LogTitle((0, 0, 3))); + Assert.Equal(string.Empty, FlowGraphView.LogTitle((0, 0, 0))); + } + + [Fact] + public void Render_OutboundNodeWithLogLines_ShowsBothThePillAndTheBadgeInTheFooter() + { + var correlation = Guid.NewGuid(); + var step = new FlowStep(Guid.NewGuid(), Root, null, FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe", + "Delivered · sync", T0, 212, FlowStepOutcome.Ok, null, null, null, "site-a", "node-a", "M1", "Delmia.AckRecipe", null, + [new FlowLifecycleRow(Guid.NewGuid(), "ApiCall", "Delivered", T0, "node-a", null, 212)], + correlation, null, [Line("warn"), Line("info")]); + + var cut = Render(p => p.Add(c => c.Graph, new FlowGraph([step], [], false, false))); + + var foot = cut.Find("g[data-test='flow-node'][data-kind='ApiCall'] .fg-foot"); + Assert.Equal("Ok", foot.QuerySelector("[data-test='flow-node-pill']")!.TextContent.Trim()); + Assert.Equal("· sync", foot.QuerySelector(".fg-mode")!.TextContent.Trim()); + var badge = foot.QuerySelector("[data-test='flow-node-logs']")!; + Assert.Equal(["! 1", "≡ 1"], badge.QuerySelectorAll("i").Select(i => i.TextContent.Trim())); + Assert.Equal("1 warning, 1 info", badge.GetAttribute("title")); + // A pill node has no subtitle line — the pill occupies the footer's left half. + Assert.Null(foot.QuerySelector("[data-test='flow-node-subtitle']")); + } }