Merge wt/step-logs-t13: Task 13 page strip counts, truncation notes, selection hookup

This commit is contained in:
Joseph Doherty
2026-09-09 12:48:26 -04:00
6 changed files with 359 additions and 8 deletions
@@ -166,6 +166,12 @@
<span><span class="fd-label">Steps</span><span class="fd-mono" data-test="flow-step-count">@_graph.Steps.Count</span></span>
<span><span class="fd-label">Boundaries</span><span class="fd-mono">@_graph.Edges.Count(e => e.CrossesBoundary)</span></span>
<span><span class="fd-label">Writes</span><span class="fd-mono">@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite)</span></span>
@* Step logs (design 2026-09-09 D5): the run's line counts by level — a Log.Error line is
NOT a failed step, so it never moves the state chip. *@
@if (LogCounts is { Total: > 0 })
{
<span><span class="fd-label">Log</span><span class="fd-mono" data-test="flow-log-counts">@LogSummary</span></span>
}
@if (PendingCount > 0)
{
<span><span class="fd-label">Pending</span><span class="fd-mono" data-test="flow-pending-count">@PendingCount</span></span>
@@ -191,6 +197,14 @@
{
<span class="badge bg-warning text-dark">event list truncated</span>
}
@if (_graph?.LogsTruncated == true)
{
<span class="badge bg-warning text-dark" data-test="flow-logs-truncated">inline log lines capped — refresh outcomes pulls the rest</span>
}
@if (_graph?.UnattachedLogLines > 0)
{
<span class="text-muted small" data-test="flow-unattached-logs">@_graph.UnattachedLogLines log line(s) could not be placed on a step</span>
}
</div>
<div class="row g-3">
@@ -284,7 +298,7 @@
SiteDbId="@(_entry == EntryKind.Script && _selectedSiteId > 0 ? _selectedSiteId : null)"
InstanceDbId="@(_entry == EntryKind.Script && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
OnRetry="RetryCallback" OnDiscard="DiscardCallback"
ActionInProgress="_actionInProgress" />
ActionInProgress="_actionInProgress" OnSelectStep="OnSelectStep" />
</div>
</div>
</div>
@@ -183,6 +183,44 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0;
/// <summary>
/// Step logs (design 2026-09-09 D5): the run's log lines summed by level over every step that
/// carries any. <c>error</c> and <c>warn</c> are counted by name (ordinal, case-insensitive —
/// the level rides the audit row as free text); everything else, <c>debug</c> included, is
/// "info", so the three counts always add up to <c>Total</c>. Lines the builder could not place
/// on a step are NOT here — they are reported separately as
/// <see cref="FlowGraph.UnattachedLogLines"/>.
/// </summary>
internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph)
{
if (graph is null)
return (0, 0, 0, 0);
var errors = 0;
var warnings = 0;
var info = 0;
foreach (var line in graph.Steps.SelectMany(s => s.Logs ?? []))
{
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, errors + warnings + info);
}
private (int Errors, int Warnings, int Info, int Total) LogCounts => CountLogs(_graph);
/// <summary>Status-strip text for <see cref="LogCounts"/>.</summary>
private string LogSummary
{
get
{
var counts = LogCounts;
return $"{counts.Errors} errors · {counts.Warnings} warnings · {counts.Info} info";
}
}
/// <summary>Pending outbound work, or an acted-on node that is still Parked, keeps the refresh loop alive.</summary>
private bool ShouldKeepPolling =>
PendingCount > 0
@@ -647,11 +685,22 @@ public partial class FlowDebugger : ComponentBase, IDisposable
var selectedId = _selected?.EventId;
var merged = MergeEvents(_raw.Events, outcome.Raw?.Events);
var truncated = _raw.Truncated || outcome.Raw?.Truncated == true;
_raw = _raw with { Events = merged, Truncated = truncated };
_graph = FlowGraphBuilder.Build(merged, truncated);
// D7: the inline copy's cap is only worth reporting while the page still holds that
// incomplete copy. A refresh MERGES rows, so the moment it brings back even one ScriptLog
// row the run did not already have, the page is no longer short of lines and the flag
// clears. Until then it stays: the site's rows reach central's audit store by telemetry
// 5-30 s later, so a refresh that adds nothing proves nothing.
var logsTruncated = (_raw.LogsTruncated || outcome.Raw?.LogsTruncated == true)
&& CountLogRows(merged) <= CountLogRows(_raw.Events);
_raw = _raw with { Events = merged, Truncated = truncated, LogsTruncated = logsTruncated };
_graph = FlowGraphBuilder.Build(merged, truncated, logsTruncated);
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
}
/// <summary>Number of <c>ScriptTrace.ScriptLog</c> rows in <paramref name="events"/> — the log lines a refresh can add to.</summary>
private static int CountLogRows(IReadOnlyList<AuditEvent>? events)
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
/// <summary>Union of two audit-row lists by <see cref="AuditEvent.EventId"/>; a row present in both takes the fresh copy.</summary>
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
{
@@ -537,8 +537,8 @@ public sealed class FlowDebugService : IFlowDebugService
if (result == null)
return FlowDebugOutcome.Fail("The server returned no result.", "BAD_RESULT");
var graph = FlowGraphBuilder.Build(result.Events ?? [], result.Truncated);
return new FlowDebugOutcome(graph, result, null, null);
var graph = FlowGraphBuilder.Build(result.Events ?? [], result.Truncated, result.LogsTruncated);
return new FlowDebugOutcome(graph, result, null, null, result.LogsTruncated);
}
case ManagementUnauthorized unauthorized:
return FlowDebugOutcome.Fail(unauthorized.Message, "UNAUTHORIZED");
@@ -15,7 +15,14 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <param name="Raw">The server's <see cref="FlowDebugRunResult"/>; null when no result came back.</param>
/// <param name="Error">Human-readable failure, null on success.</param>
/// <param name="ErrorCode">Machine-readable failure code (<c>STANDBY_NODE</c>, <c>NO_TRIGGER_WITHIN_WINDOW</c>, <c>UNAUTHORIZED</c>, <c>SERVICE_UNAVAILABLE</c>, …), null on success.</param>
public sealed record FlowDebugOutcome(FlowGraph? Graph, FlowDebugRunResult? Raw, string? Error, string? ErrorCode)
/// <param name="LogsTruncated">
/// Step logs (design 2026-09-09 D7): true when the inline trace collector dropped low-priority
/// <c>ScriptLog</c> rows to protect room for the step rows — the run's lines are incomplete inline,
/// while Replay (which reads the never-truncated audit store) is complete. Mirrors
/// <see cref="FlowDebugRunResult.LogsTruncated"/> so the page can note it without unwrapping the raw result.
/// </param>
public sealed record FlowDebugOutcome(
FlowGraph? Graph, FlowDebugRunResult? Raw, string? Error, string? ErrorCode, bool LogsTruncated = false)
{
/// <summary>True when a graph was built (the run may still have finished incomplete — see <see cref="Raw"/>).</summary>
public bool Success => Graph != null && Error == null;
@@ -903,4 +903,259 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal(string.Empty, cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList());
}
// ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ----
/// <summary>One <c>ScriptTrace.ScriptLog</c> row — a line the builder folds onto a step, never a node of its own.</summary>
private static AuditEvent LogLine(Guid execution, string level, int ms, Guid? stepEventId = null, string message = "hello") =>
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: DateTime.UtcNow.AddMilliseconds(ms), actor: "Process", target: "Process", sourceNode: "node-a",
executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "Sim01", requestSummary: message,
extra: new ScriptTraceExtra { LogLevel = level, LogSource = "script", StepEventId = stepEventId }.ToJson());
/// <summary>
/// The <see cref="GraphOutcome"/> flow (inbound → route → site run) whose site run carries the asked-for
/// log lines, optionally with <paramref name="unattached"/> lines belonging to an execution with no steps
/// (the builder drops and counts those) and the inline log-truncation flag set.
/// </summary>
private static FlowDebugOutcome LoggedOutcome(int errors = 0, int warnings = 0, int infos = 0,
bool logsTruncated = false, int unattached = 0)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var t0 = DateTime.UtcNow;
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
requestSummary: "{}", responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
};
var ms = 3;
for (var i = 0; i < errors; i++) events.Add(LogLine(child, "error", ms++));
for (var i = 0; i < warnings; i++) events.Add(LogLine(child, "warn", ms++));
for (var i = 0; i < infos; i++) events.Add(LogLine(child, "info", ms++));
for (var i = 0; i < unattached; i++) events.Add(LogLine(Guid.NewGuid(), "info", ms++));
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), logsTruncated);
return new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, logsTruncated), raw, null, null, logsTruncated);
}
/// <summary>Picks the method, runs it against <paramref name="outcome"/> and waits for the status strip.</summary>
private IRenderedComponent<FlowDebuggerPage> RunWith(FlowDebugOutcome outcome)
{
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(outcome);
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForElement("[data-test='flow-step-count']");
return cut;
}
[Fact]
public void StatusStrip_ShowsLogCountsByLevel()
{
RegisterServices();
var cut = RunWith(LoggedOutcome(errors: 1, warnings: 2, infos: 3));
Assert.Equal("1 errors · 2 warnings · 3 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
[Fact]
public void StatusStrip_OmitsLogCounts_WhenTheRunLoggedNothing()
{
RegisterServices();
var cut = RunWith(LoggedOutcome());
Assert.Empty(cut.FindAll("[data-test='flow-log-counts']"));
Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']"));
Assert.Empty(cut.FindAll("[data-test='flow-unattached-logs']"));
}
[Fact]
public void StatusStrip_ShowsTheInlineLogTruncationBadge()
{
// D7: the inline copy dropped low-priority rows; the audit store — and so Replay — is complete.
RegisterServices();
var cut = RunWith(LoggedOutcome(infos: 1, logsTruncated: true));
Assert.Contains("inline log lines capped", cut.Find("[data-test='flow-logs-truncated']").TextContent);
}
[Fact]
public void StatusStrip_NotesLinesThatCouldNotBePlacedOnAStep()
{
RegisterServices();
var cut = RunWith(LoggedOutcome(infos: 1, unattached: 2));
Assert.Contains("2 log line(s) could not be placed", cut.Find("[data-test='flow-unattached-logs']").TextContent);
// The dropped lines are not counted on the strip — it counts only what landed on a step.
Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
[Fact]
public void StateChip_StaysCompleted_WhenTheOnlyErrorsAreLogLines()
{
// A Log.Error line is not a failed step: the run state is decided by step outcomes alone.
RegisterServices();
var cut = RunWith(LoggedOutcome(errors: 2));
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
Assert.Equal("2 errors · 0 warnings · 0 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
[Fact]
public void StepDetail_LogHint_SelectsTheSiblingStepCarryingTheOtherLines()
{
RegisterServices();
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var writeId = Guid.NewGuid();
var t0 = DateTime.UtcNow;
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
requestSummary: "{}", responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
eventId: writeId, occurredAtUtc: t0.AddMilliseconds(3), target: "Sim01.Flag", executionId: child,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 2,
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("Sim01.Flag", "true", true, null)] }.ToJson()),
LogLine(child, "info", 4), // → the ScriptRun anchor
LogLine(child, "warn", 5, stepEventId: writeId), // → the write step
};
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
var cut = RunWith(new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null));
cut.Find("g[data-test='flow-node'][data-kind='ScriptRun']").Click();
cut.Find("[data-test='flow-step-tab-log']").Click();
var before = cut.Find("[data-test='flow-step-title']").TextContent.Trim();
var hint = cut.Find("[data-test='flow-step-log-hint'] button");
var otherTitle = hint.TextContent.Trim();
hint.Click();
// The page owns the selection: clicking the hint moves the detail panel AND the graph's
// highlight to the sibling step.
Assert.NotEqual(before, otherTitle);
Assert.Equal(otherTitle, cut.Find("[data-test='flow-step-title']").TextContent.Trim());
var selected = cut.Find("[data-test='flow-node'].selected");
Assert.Equal("e:" + writeId.ToString("N"), selected.GetAttribute("data-key"));
}
// ---- D7: the inline cap flag is only true while the page is still short of lines --------------
/// <summary>
/// A Live run that reported inline log truncation, plus the Replay a refresh reads back — sharing the
/// run's own event objects (stable ids, as real audit rows are) and, when asked, one more log row than
/// the run held.
/// </summary>
private static (FlowDebugOutcome Run, FlowDebugOutcome Refreshed, Guid Root) TruncatedThenRefreshed(bool refreshAddsALine)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var t0 = DateTime.UtcNow.AddSeconds(-5);
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
requestSummary: "{}", responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
LogLine(child, "info", 3),
};
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), true);
var run = new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, true), raw, null, null, true);
// The audit store is never truncated, so the re-read's own flag is false whatever it holds.
var reread = new List<AuditEvent>(events);
if (refreshAddsALine)
reread.Add(LogLine(child, "warn", 4));
var rereadRaw = new FlowDebugRunResult(root, true, null, null, reread, false, t0, t0.AddSeconds(1));
var refreshed = new FlowDebugOutcome(FlowGraphBuilder.Build(reread), rereadRaw, null, null);
return (run, refreshed, root);
}
[Fact]
public void RefreshOutcomes_ClearsTheInlineCapBadge_OnceItMergesInMoreLogRows()
{
RegisterServices();
var (run, refreshed, root) = TruncatedThenRefreshed(refreshAddsALine: true);
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(refreshed);
var cut = RunWith(run);
cut.Instance.Delay = (_, _) => Task.CompletedTask;
Assert.NotNull(cut.Find("[data-test='flow-logs-truncated']"));
cut.Find("button[data-test='flow-refresh']").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']")));
Assert.Equal("0 errors · 1 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
[Fact]
public void RefreshOutcomes_KeepsTheInlineCapBadge_WhenTheReReadBringsBackNoNewLines()
{
// The site's rows reach central by telemetry seconds later — a refresh that adds nothing
// has not proven the page holds every line.
RegisterServices();
var (run, refreshed, root) = TruncatedThenRefreshed(refreshAddsALine: false);
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(refreshed);
var cut = RunWith(run);
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("button[data-test='flow-refresh']").Click();
cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>()));
Assert.NotNull(cut.Find("[data-test='flow-logs-truncated']"));
Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
// ---- CountLogs: the level buckets, straight ---------------------------------------------------
[Fact]
public void CountLogs_BucketsByLevel_CaseInsensitively_WithEverythingElseAsInfo()
{
var execution = Guid.NewGuid();
var t0 = DateTime.UtcNow;
var graph = FlowGraphBuilder.Build([
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0, target: "Process", executionId: execution,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
LogLine(execution, "ERROR", 1),
LogLine(execution, "Warn", 2),
LogLine(execution, "info", 3),
LogLine(execution, "debug", 4),
LogLine(execution, "trace", 5), // an unknown level is still a line — it lands in info
]);
Assert.Equal((1, 1, 3, 5), FlowDebuggerPage.CountLogs(graph));
}
[Fact]
public void CountLogs_NullGraph_IsAllZeroes()
{
Assert.Equal((0, 0, 0, 0), FlowDebuggerPage.CountLogs(null));
}
}
@@ -253,7 +253,7 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.IsType<DiscardSiteCallRequest>(Assert.Single(_siteCallRequests));
}
private static string SuccessPayload(int events, bool completed = true, bool truncated = false)
private static string SuccessPayload(int events, bool completed = true, bool truncated = false, bool logsTruncated = false)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
@@ -273,10 +273,36 @@ public sealed class FlowDebugServiceTests : IDisposable
occurredAtUtc: t0.AddMilliseconds(2), target: "S", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "I", durationMs: 80));
var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1));
var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1), logsTruncated);
return JsonSerializer.Serialize(result, ServerJson);
}
[Fact]
public async Task RunMethod_LogsTruncated_RidesOntoTheOutcomeAndTheGraph()
{
// Step logs D7: the run result's inline log-truncation flag is the page's only way to
// know the lines it holds are incomplete — carry it onto the outcome AND into the graph.
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3, logsTruncated: true)));
var outcome = await svc.RunMethodAsync(4, null);
Assert.True(outcome.Success);
Assert.True(outcome.LogsTruncated);
Assert.True(outcome.Graph!.LogsTruncated);
Assert.True(outcome.Raw!.LogsTruncated);
}
[Fact]
public async Task RunMethod_NoLogTruncation_LeavesTheFlagFalse()
{
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3)));
var outcome = await svc.RunMethodAsync(4, null);
Assert.False(outcome.LogsTruncated);
Assert.False(outcome.Graph!.LogsTruncated);
}
[Fact]
public async Task RunMethod_HolderNull_ReportsServiceUnavailable()
{