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 a5ad8863..19b41fab 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor @@ -104,7 +104,7 @@ } @if (logs.Errors + logs.Warnings + logs.Info > 0) { - + @if (logs.Errors > 0) { ✕ @logs.Errors 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 c905db6c..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 @@ -260,9 +260,9 @@ public partial class FlowGraphView : ComponentBase } /// Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info"). - internal static string LogTitle(FlowLayoutNode n) + internal static string LogTitle((int Errors, int Warnings, int Info) counts) { - var (errors, warnings, info) = LogCounts(n.Step); + 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")}"); @@ -274,7 +274,10 @@ public partial class FlowGraphView : ComponentBase /// 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). - /// Falls back to the first line verbatim when it carries no ": ", and to + /// 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) @@ -283,6 +286,9 @@ public partial class FlowGraphView : ComponentBase 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); @@ -290,8 +296,12 @@ public partial class FlowGraphView : ComponentBase var type = first[..split]; var message = first[(split + 2)..]; - var dot = type.LastIndexOf('.'); - if (dot >= 0 && dot < type.Length - 1) type = type[(dot + 1)..]; + // 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; } 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 f877c9eb..539fa18a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs @@ -667,6 +667,16 @@ public class FlowGraphViewTests : BunitContext [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) { @@ -678,12 +688,31 @@ public class FlowGraphViewTests : BunitContext [Fact] public void LogTitle_NamesOnlyTheNonZeroLevels_WithSingularAndPlural() { - FlowLayoutNode Node(IReadOnlyList? logs) - => new("k", FlowLane.Site, 0, 0, 0, LogGraph(logs: logs).Steps[0], null, false); + 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))); + } - Assert.Equal("1 error", FlowGraphView.LogTitle(Node([Line("error")]))); - Assert.Equal("2 errors, 1 warning", FlowGraphView.LogTitle(Node([Line("error"), Line("error"), Line("warn")]))); - Assert.Equal("1 warning, 2 info", FlowGraphView.LogTitle(Node([Line("warn"), Line("info"), Line("debug")]))); - Assert.Equal(string.Empty, FlowGraphView.LogTitle(Node(null))); + [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']")); } }