diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor
index 1dfb1a66..a3098293 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor
@@ -166,6 +166,12 @@
Steps@_graph.Steps.Count
Boundaries@_graph.Edges.Count(e => e.CrossesBoundary)
Writes@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite)
+ @* 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 })
+ {
+ Log@LogSummary
+ }
@if (PendingCount > 0)
{
Pending@PendingCount
@@ -191,6 +197,14 @@
{
event list truncated
}
+ @if (_graph?.LogsTruncated == true)
+ {
+ log lines truncated inline — Replay is complete
+ }
+ @if (_graph?.UnattachedLogLines > 0)
+ {
+ @_graph.UnattachedLogLines log line(s) could not be placed on a step
+ }
@@ -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" />
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
index 0c9608b9..2e48da63 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs
@@ -183,6 +183,44 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0;
+ ///
+ /// Step logs (design 2026-09-09 D5): the run's log lines summed by level over every step that
+ /// carries any. error and warn are counted by name (ordinal, case-insensitive —
+ /// the level rides the audit row as free text); everything else, debug included, is
+ /// "info", so the three counts always add up to Total. Lines the builder could not place
+ /// on a step are NOT here — they are reported separately as
+ /// .
+ ///
+ 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);
+
+ /// Status-strip text for .
+ private string LogSummary
+ {
+ get
+ {
+ var counts = LogCounts;
+ return $"{counts.Errors} errors · {counts.Warnings} warnings · {counts.Info} info";
+ }
+ }
+
/// Pending outbound work, or an acted-on node that is still Parked, keeps the refresh loop alive.
private bool ShouldKeepPolling =>
PendingCount > 0
@@ -647,8 +685,11 @@ 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);
+ // Truncation is sticky across a refresh, exactly as the event-list flag is: the Live run's
+ // inline copy stays the incomplete one whatever the re-read brought back (D7).
+ var logsTruncated = _raw.LogsTruncated || outcome.Raw?.LogsTruncated == true;
+ _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;
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs
index 4d937cca..a1fef15f 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs
@@ -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");
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs
index 238bba12..eb3d33b3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IFlowDebugService.cs
@@ -15,7 +15,14 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// The server's ; null when no result came back.
/// Human-readable failure, null on success.
/// Machine-readable failure code (STANDBY_NODE, NO_TRIGGER_WITHIN_WINDOW, UNAUTHORIZED, SERVICE_UNAVAILABLE, …), null on success.
-public sealed record FlowDebugOutcome(FlowGraph? Graph, FlowDebugRunResult? Raw, string? Error, string? ErrorCode)
+///
+/// Step logs (design 2026-09-09 D7): true when the inline trace collector dropped low-priority
+/// ScriptLog 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
+/// so the page can note it without unwrapping the raw result.
+///
+public sealed record FlowDebugOutcome(
+ FlowGraph? Graph, FlowDebugRunResult? Raw, string? Error, string? ErrorCode, bool LogsTruncated = false)
{
/// True when a graph was built (the run may still have finished incomplete — see ).
public bool Success => Graph != null && Error == null;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
index 6bde8719..ef8bb157 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
@@ -903,4 +903,158 @@ 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 ----
+
+ /// One ScriptTrace.ScriptLog row — a line the builder folds onto a step, never a node of its own.
+ 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());
+
+ ///
+ /// The flow (inbound → route → site run) whose site run carries the asked-for
+ /// log lines, optionally with lines belonging to an execution with no steps
+ /// (the builder drops and counts those) and the inline log-truncation flag set.
+ ///
+ 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
+ {
+ 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);
+ }
+
+ /// Picks the method, runs it against and waits for the status strip.
+ private IRenderedComponent RunWith(FlowDebugOutcome outcome)
+ {
+ _flow.RunMethodAsync(4, Arg.Any(), Arg.Any()).Returns(outcome);
+ var cut = Render();
+ 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("Replay is complete", 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
+ {
+ 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 to the sibling step.
+ Assert.NotEqual(before, otherTitle);
+ Assert.Equal(otherTitle, cut.Find("[data-test='flow-step-title']").TextContent.Trim());
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs
index 21fa0eb6..a3bb918b 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/FlowDebugServiceTests.cs
@@ -253,7 +253,7 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.IsType(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()
{