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
@@ -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",