Merge wt/step-logs-t11: Task 11
This commit is contained in:
@@ -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(logs)">
|
||||
@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,74 @@ 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((int Errors, int Warnings, int Info) counts)
|
||||
{
|
||||
var (errors, warnings, info) = counts;
|
||||
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>).
|
||||
/// A wrapper's first line chains its inner exceptions with <c>" ---> "</c>; 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 (<c>`1[System.String]</c>)
|
||||
/// is kept verbatim. Falls back to the line itself 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();
|
||||
// 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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user