feat(central-ui): Flow Debugger refreshes pending outbound outcomes after a Live run
After a successful Live run whose graph has Pending outbound steps the page re-reads the tree through the existing Replay command every 5 s for up to 2 min (page constants), merging by replacing the graph and keeping the selection by event id; never in Replay mode; cancelled by a new run, a mode switch or Dispose. Status strip gains a Pending count, a 'Refresh outcomes' button (both modes) and an 'outcomes still arriving' note while polling. The run's own timing and the Completed chip are left alone — pending outbound work is not a failed run. Plan 2026-09-06 D5. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -68,7 +68,7 @@
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: FlowStepDetail \u2014 Lifecycle tab and outbound fields",
|
||||
"status": "in_progress",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
2
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: Flow Debugger page \u2014 pending refresh after a Live run",
|
||||
"status": "pending",
|
||||
"status": "in_progress",
|
||||
"blockedBy": [
|
||||
7,
|
||||
8
|
||||
@@ -96,5 +96,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-09-06T05:57:47.424541+00:00"
|
||||
"lastUpdated": "2026-09-06T05:59:55.586055+00:00"
|
||||
}
|
||||
@@ -88,9 +88,9 @@
|
||||
<label class="form-label form-label-sm">Mode</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Mode">
|
||||
<button type="button" class="btn @(_mode == FlowMode.Live ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-live" disabled="@_running" @onclick="() => _mode = FlowMode.Live">Live</button>
|
||||
data-test="flow-mode-live" disabled="@_running" @onclick="() => SetMode(FlowMode.Live)">Live</button>
|
||||
<button type="button" class="btn @(_mode == FlowMode.Replay ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-replay" disabled="@_running" @onclick="() => _mode = FlowMode.Replay">Replay</button>
|
||||
data-test="flow-mode-replay" disabled="@_running" @onclick="() => SetMode(FlowMode.Replay)">Replay</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +144,19 @@
|
||||
<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>
|
||||
@if (PendingCount > 0)
|
||||
{
|
||||
<span><span class="fd-label">Pending</span><span class="fd-mono" data-test="flow-pending-count">@PendingCount</span></span>
|
||||
}
|
||||
}
|
||||
@if (_raw != null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0" data-test="flow-refresh"
|
||||
disabled="@(!CanRefresh)" @onclick="RefreshOutcomesAsync">Refresh outcomes</button>
|
||||
}
|
||||
@if (_refreshing)
|
||||
{
|
||||
<span class="text-muted small" data-test="flow-pending-note">outcomes still arriving — refreshing</span>
|
||||
}
|
||||
@if (_raw != null)
|
||||
{
|
||||
|
||||
+122
-1
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||
/// <c>Idle → Predicted → Running → Completed | Failed | TimedOut</c>. The server's
|
||||
/// <c>STANDBY_NODE</c> refusal renders as an inline alert with the message verbatim.
|
||||
/// </summary>
|
||||
public partial class FlowDebugger : ComponentBase
|
||||
public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
{
|
||||
/// <summary>Entry point kinds.</summary>
|
||||
public enum EntryKind
|
||||
@@ -116,6 +116,21 @@ public partial class FlowDebugger : ComponentBase
|
||||
private bool _running;
|
||||
private bool _storageRestored;
|
||||
|
||||
// Outbound calls (plan 2026-09-06 D5): after a Live run whose graph still has Pending
|
||||
// outbound steps, re-read the tree through the Replay command until every step is terminal
|
||||
// or the window closes. Page constants, not options — nothing operational depends on them.
|
||||
internal static readonly TimeSpan PendingRefreshInterval = TimeSpan.FromSeconds(5);
|
||||
internal static readonly TimeSpan PendingRefreshWindow = TimeSpan.FromMinutes(2);
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private bool _refreshing;
|
||||
|
||||
/// <summary>Delay seam for the pending-refresh loop (tests inject an immediate delay).</summary>
|
||||
internal Func<TimeSpan, CancellationToken, Task> Delay { get; set; } = Task.Delay;
|
||||
|
||||
private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0;
|
||||
|
||||
private bool CanRefresh => !_running && _raw is not null;
|
||||
|
||||
private bool CanRun => !_running && _mode == FlowMode.Live && (
|
||||
_entry == EntryKind.Method ? _selectedMethodId > 0
|
||||
: _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute));
|
||||
@@ -350,9 +365,17 @@ public partial class FlowDebugger : ComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
private void SetMode(FlowMode mode)
|
||||
{
|
||||
if (_mode == mode) return;
|
||||
CancelPendingRefresh();
|
||||
_mode = mode;
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
if (!CanRun) return;
|
||||
CancelPendingRefresh();
|
||||
_running = true;
|
||||
_state = FlowState.Running;
|
||||
_error = null;
|
||||
@@ -387,11 +410,108 @@ public partial class FlowDebugger : ComponentBase
|
||||
{
|
||||
_running = false;
|
||||
}
|
||||
|
||||
StartPendingRefresh();
|
||||
}
|
||||
|
||||
/// <summary>Begins polling when a completed Live run left outbound work in flight. Never in Replay mode.</summary>
|
||||
private void StartPendingRefresh()
|
||||
{
|
||||
if (_mode != FlowMode.Live || _raw is null || _graph is null || PendingCount == 0)
|
||||
return;
|
||||
var cts = new CancellationTokenSource();
|
||||
_refreshCts = cts;
|
||||
_ = RefreshPendingLoopAsync(_raw.RootExecutionId, cts);
|
||||
}
|
||||
|
||||
private async Task RefreshPendingLoopAsync(Guid root, CancellationTokenSource cts)
|
||||
{
|
||||
var ct = cts.Token;
|
||||
_refreshing = true;
|
||||
var deadline = DateTime.UtcNow + PendingRefreshWindow;
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Delay(PendingRefreshInterval, ct);
|
||||
var outcome = await FlowDebug.ReplayAsync(root, ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
MergeRefreshedGraph(outcome);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
if (PendingCount == 0) return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// New run, mode switch or dispose — the loop is simply abandoned.
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Best-effort: the manual Refresh button stays available.
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_refreshCts, cts))
|
||||
{
|
||||
_refreshing = false;
|
||||
_refreshCts = null;
|
||||
cts.Dispose();
|
||||
try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Manual refresh — one Replay read of the run's tree; works in both modes.</summary>
|
||||
private async Task RefreshOutcomesAsync()
|
||||
{
|
||||
if (!CanRefresh) return;
|
||||
var root = _raw!.RootExecutionId;
|
||||
_running = true;
|
||||
try
|
||||
{
|
||||
MergeRefreshedGraph(await FlowDebug.ReplayAsync(root));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = "Refresh failed: " + ex.Message;
|
||||
_errorCode = "REFRESH_FAILED";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the graph with the re-read tree and keeps the selection by event id. The run's own
|
||||
/// <c>_raw</c> (timing, root) and the state chip are deliberately left alone: pending outbound
|
||||
/// work is not a failed run, and the run's Started/Total are the truthful numbers.
|
||||
/// </summary>
|
||||
private void MergeRefreshedGraph(FlowDebugOutcome outcome)
|
||||
{
|
||||
if (!outcome.Success || outcome.Graph == null || _graph == null)
|
||||
return;
|
||||
var selectedId = _selected?.EventId;
|
||||
_graph = outcome.Graph;
|
||||
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
|
||||
}
|
||||
|
||||
private void CancelPendingRefresh()
|
||||
{
|
||||
var cts = _refreshCts;
|
||||
if (cts is null) return;
|
||||
_refreshCts = null;
|
||||
_refreshing = false;
|
||||
try { cts.Cancel(); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => CancelPendingRefresh();
|
||||
|
||||
private async Task LoadReplayAsync()
|
||||
{
|
||||
if (!Guid.TryParse(_replayExecutionIdText, out var exec)) return;
|
||||
CancelPendingRefresh();
|
||||
_running = true;
|
||||
_state = FlowState.Running;
|
||||
_error = null;
|
||||
@@ -447,6 +567,7 @@ public partial class FlowDebugger : ComponentBase
|
||||
|
||||
private void ResetResult()
|
||||
{
|
||||
CancelPendingRefresh();
|
||||
_graph = null;
|
||||
_raw = null;
|
||||
_selected = null;
|
||||
|
||||
@@ -209,4 +209,96 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
|
||||
cut.WaitForElement("[data-test='flow-site-not-traced']");
|
||||
}
|
||||
|
||||
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
|
||||
|
||||
/// <summary>Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>.</summary>
|
||||
private static FlowDebugOutcome OutboundOutcome(Guid root, bool resolved)
|
||||
{
|
||||
var child = Guid.NewGuid();
|
||||
var corr = Guid.NewGuid();
|
||||
var t0 = DateTime.UtcNow.AddSeconds(-10);
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100, 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.ApiOutbound, AuditKind.CachedSubmit, AuditStatus.Submitted,
|
||||
occurredAtUtc: t0.AddMilliseconds(3), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
|
||||
};
|
||||
if (resolved)
|
||||
events.Add(ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01"));
|
||||
|
||||
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
|
||||
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveRun_WithPendingOutbound_PollsReplayUntilTerminal()
|
||||
{
|
||||
RegisterServices();
|
||||
var root = Guid.NewGuid();
|
||||
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false));
|
||||
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false), OutboundOutcome(root, resolved: true));
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
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.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-pending-count']")));
|
||||
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-pending-note']"));
|
||||
Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok");
|
||||
Assert.NotNull(cut.Find("button[data-test='flow-refresh']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveRun_WithNoPendingStep_NeverPolls()
|
||||
{
|
||||
RegisterServices();
|
||||
var root = Guid.NewGuid();
|
||||
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: true));
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
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.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
||||
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-pending-count']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayMode_NeverPolls_ButRefreshButtonReplaysOnce()
|
||||
{
|
||||
RegisterServices();
|
||||
var root = Guid.NewGuid();
|
||||
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(OutboundOutcome(root, resolved: false));
|
||||
var nav = Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Instance.Delay = (_, _) => Task.CompletedTask;
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
|
||||
Assert.Equal("1", cut.Find("[data-test='flow-pending-count']").TextContent.Trim());
|
||||
_flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>());
|
||||
|
||||
cut.Find("button[data-test='flow-refresh']").Click();
|
||||
|
||||
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
|
||||
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user