feat(central-ui): FlowGraphView log-count badge and exception subtitle on failed nodes (D8)

This commit is contained in:
Joseph Doherty
2026-09-09 11:36:42 -04:00
parent 25b127b3e3
commit a4a35bfa97
4 changed files with 232 additions and 16 deletions
@@ -70,6 +70,7 @@
@foreach (var n in _layout.Nodes)
{
var node = n;
var logs = LogCounts(node.Step);
<g class="@NodeClass(node)" transform="translate(@F(node.X) @F(node.Y))"
role="button" tabindex="0"
aria-label="@(Eyebrow(node) + ": " + node.Title)"
@@ -86,18 +87,39 @@
<span class="fg-duration">@Duration(node)</span>
</div>
<div class="fg-title" data-test="flow-node-title">@WrapHints(node.DisplayTitle)</div>
@if (HasPill(node))
{
@* Outbound kinds: the folded outcome as a pill, then the call mode (node anatomy: "● Ok · sync"). *@
<div class="fg-status" data-test="flow-node-status">
<span class="@PillClass(node.Step!.Outcome)" data-test="flow-node-pill">@PillText(node.Step.Outcome)</span>
<span class="fg-mode">· @OutboundMode(node.Kind, node.Subtitle)</span>
</div>
}
else
{
<div class="fg-subtitle" data-test="flow-node-subtitle">@node.DisplaySubtitle</div>
}
@* 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. *@
<div class="fg-foot">
@if (HasPill(node))
{
@* Outbound kinds: the folded outcome as a pill, then the call mode (node anatomy: "● Ok · sync"). *@
<div class="fg-status" data-test="flow-node-status">
<span class="@PillClass(node.Step!.Outcome)" data-test="flow-node-pill">@PillText(node.Step.Outcome)</span>
<span class="fg-mode">· @OutboundMode(node.Kind, node.Subtitle)</span>
</div>
}
else
{
<div class="fg-subtitle" data-test="flow-node-subtitle">@node.DisplaySubtitle</div>
}
@if (logs.Errors + logs.Warnings + logs.Info > 0)
{
<span class="fg-logs" data-test="flow-node-logs" title="@LogTitle(node)">
@if (logs.Errors > 0)
{
<i class="e">✕ @logs.Errors</i>
}
@if (logs.Warnings > 0)
{
<i class="w">! @logs.Warnings</i>
}
@if (logs.Info > 0)
{
<i class="i">≡ @logs.Info</i>
}
</span>
}
</div>
</div>
</foreignObject>
</g>
@@ -40,8 +40,14 @@ public sealed record FlowLayoutNode(
/// <summary>Title with <see cref="LanePrefix"/> removed — the discriminating tail of a dotted path.</summary>
public string DisplayTitle => FlowGraphView.StripPrefix(Title, LanePrefix) ?? Title;
/// <summary>Subtitle with <see cref="LanePrefix"/> removed from each comma-separated item.</summary>
public string? DisplaySubtitle => FlowGraphView.StripPrefix(Subtitle, LanePrefix);
/// <summary>
/// Subtitle with <see cref="LanePrefix"/> 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.
/// </summary>
public string? DisplaySubtitle => (Step != null ? FlowGraphView.ExceptionSubtitle(Step) : null)
?? FlowGraphView.StripPrefix(Subtitle, LanePrefix);
/// <summary>Full, unclipped text for the hover tooltip.</summary>
public string FullText => string.IsNullOrEmpty(Subtitle) ? Title : Title + " — " + Subtitle;
@@ -231,6 +237,64 @@ public partial class FlowGraphView : ComponentBase
return detail.Length == 0 ? "sync" : detail + " · sync";
}
/// <summary>
/// Log lines on a step folded to per-severity counts for the footer badge (step-logs design
/// 2026-09-09 D8). <c>error</c> and <c>warn</c> are counted by name (ordinal-ignore-case);
/// everything else — <c>info</c>, <c>debug</c>, an unknown level — counts as info, so the badge
/// never silently drops a line.
/// </summary>
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);
}
/// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary>
internal static string LogTitle(FlowLayoutNode n)
{
var (errors, warnings, info) = LogCounts(n.Step);
var parts = new List<string>(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);
}
/// <summary>
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
/// the type is the exception's name without its namespace, parsed from the first line of the captured
/// <see cref="FlowStep.ErrorDetail"/> (a .NET <c>ToString()</c> opens with <c>Namespace.Type: message</c>).
/// Falls back to the first line verbatim when it carries no <c>": "</c>, and to
/// <see cref="FlowStep.ErrorMessage"/> when there is no detail. Null for every non-failed step.
/// </summary>
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();
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)..];
var dot = type.LastIndexOf('.');
if (dot >= 0 && dot < type.Length - 1) type = type[(dot + 1)..];
return type + ": " + message;
}
private static string Eyebrow(FlowLayoutNode n) => n.Kind switch
{
FlowStepKind.InboundRequest => "REQUEST",
@@ -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;
@@ -566,4 +566,124 @@ 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);
/// <summary>A one-node Site-lane graph whose single step carries the given logs / error shape.</summary>
private static FlowGraph LogGraph(
FlowStepOutcome outcome = FlowStepOutcome.Ok,
string? errorMessage = null,
string? errorDetail = null,
IReadOnlyList<FlowLogLine>? 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<FlowGraphView>(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<FlowLogLine>
{
Line("error"), Line("warn"), Line("WARN"), Line("info"), Line("info"), Line("info"), Line("debug"),
};
var cut = Render<FlowGraphView>(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<FlowGraphView>(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<FlowGraphView>(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<FlowGraphView>(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<FlowGraphView>(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)]
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()
{
FlowLayoutNode Node(IReadOnlyList<FlowLogLine>? logs)
=> new("k", FlowLane.Site, 0, 0, 0, LogGraph(logs: logs).Steps[0], null, false);
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)));
}
}