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 a3098293..c3fa2338 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
@@ -199,7 +199,7 @@
}
@if (_graph?.LogsTruncated == true)
{
- log lines truncated inline — Replay is complete
+ inline log lines capped — refresh outcomes pulls the rest
}
@if (_graph?.UnattachedLogLines > 0)
{
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 2e48da63..6d2369f6 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
@@ -685,14 +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;
- // 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;
+ // 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;
}
+ /// Number of ScriptTrace.ScriptLog rows in — the log lines a refresh can add to.
+ private static int CountLogRows(IReadOnlyList? events)
+ => events?.Count(FlowStepProjector.IsLogLine) ?? 0;
+
/// Union of two audit-row lists by ; a row present in both takes the fresh copy.
internal static IReadOnlyList MergeEvents(IReadOnlyList? known, IReadOnlyList? fresh)
{
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 ef8bb157..c5540f17 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs
@@ -989,7 +989,7 @@ public class FlowDebuggerPageTests : BunitContext
var cut = RunWith(LoggedOutcome(infos: 1, logsTruncated: true));
- Assert.Contains("Replay is complete", cut.Find("[data-test='flow-logs-truncated']").TextContent);
+ Assert.Contains("inline log lines capped", cut.Find("[data-test='flow-logs-truncated']").TextContent);
}
[Fact]
@@ -1053,8 +1053,109 @@ public class FlowDebuggerPageTests : BunitContext
hint.Click();
- // The page owns the selection: clicking the hint moves the detail panel to the sibling step.
+ // 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 --------------
+
+ ///
+ /// 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.
+ ///
+ 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
+ {
+ 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(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()).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()).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()));
+ 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));
}
}