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)); } }