feat(central-ui): Retry/Discard a parked outbound node from the Flow Debugger with scope check, confirm and re-poll
The page wires FlowStepDetail's OnRetry/OnDiscard to IFlowDebugService.RetryOutboundAsync / DiscardOutboundAsync behind the same site-scope re-check and confirm wording the Site Calls / Notification report pages use, toasts the relay outcome, and on success re-polls Replay (same 5 s / 2 min loop) until the acted node leaves Parked. Also drops a duplicated ?correlationId= test block that a concurrent index-only commit left in QueryStringDrillInTests. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Flow Debugger</h4>
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@* ── Toolbar ─────────────────────────────────────────────────────────── *@
|
||||
<div class="row g-2 align-items-end mb-2" data-test="flow-toolbar">
|
||||
@@ -233,7 +234,8 @@
|
||||
<div class="col-12">
|
||||
<FlowStepDetail Step="_selected" Graph="_graph"
|
||||
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)"
|
||||
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)" />
|
||||
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
|
||||
OnRetry="OnRetryStepAsync" OnDiscard="OnDiscardStepAsync" ActionInProgress="_actionInProgress" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+130
-3
@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
@@ -73,6 +74,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
|
||||
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
[Inject] private IDialogService Dialog { get; set; } = default!;
|
||||
|
||||
/// <summary>Drill-in: pre-select an inbound method.</summary>
|
||||
[SupplyParameterFromQuery] public int? MethodId { get; set; }
|
||||
@@ -124,11 +126,23 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
private bool _refreshing;
|
||||
|
||||
// Node actions (follow-ups plan 2026-09-06 D5/D6): Retry / Discard on a Parked External node.
|
||||
// The relay itself lives in IFlowDebugService; the page owns the site-scope re-check, the
|
||||
// confirm dialog and the re-poll that watches the acted node until it leaves Parked.
|
||||
private ToastNotification _toast = default!;
|
||||
private bool _actionInProgress;
|
||||
private Guid? _watchedStepId;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>Pending outbound work, or an acted-on node that is still Parked, keeps the refresh loop alive.</summary>
|
||||
private bool ShouldKeepPolling =>
|
||||
PendingCount > 0
|
||||
|| (_watchedStepId is { } w && _graph?.Steps.Any(s => s.EventId == w && s.Outcome == FlowStepOutcome.Parked) == true);
|
||||
|
||||
private bool CanRefresh => !_running && _raw is not null;
|
||||
|
||||
private bool CanRun => !_running && _mode == FlowMode.Live && (
|
||||
@@ -417,8 +431,20 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
/// <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)
|
||||
if (_mode != FlowMode.Live || PendingCount == 0)
|
||||
return;
|
||||
StartRefreshLoop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts (or restarts) the bounded refresh loop while <see cref="ShouldKeepPolling"/> holds — after a
|
||||
/// Live run with Pending nodes, and after a Retry/Discard relay in either mode.
|
||||
/// </summary>
|
||||
private void StartRefreshLoop()
|
||||
{
|
||||
if (_raw is null || _graph is null || !ShouldKeepPolling)
|
||||
return;
|
||||
CancelPendingRefresh(keepWatch: true);
|
||||
var cts = new CancellationTokenSource();
|
||||
_refreshCts = cts;
|
||||
_ = RefreshPendingLoopAsync(_raw.RootExecutionId, cts);
|
||||
@@ -438,7 +464,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
if (ct.IsCancellationRequested) return;
|
||||
MergeRefreshedGraph(outcome);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
if (PendingCount == 0) return;
|
||||
if (!ShouldKeepPolling) return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -455,6 +481,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
{
|
||||
_refreshing = false;
|
||||
_refreshCts = null;
|
||||
_watchedStepId = null;
|
||||
cts.Dispose();
|
||||
try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
@@ -496,8 +523,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
|
||||
}
|
||||
|
||||
private void CancelPendingRefresh()
|
||||
private void CancelPendingRefresh(bool keepWatch = false)
|
||||
{
|
||||
if (!keepWatch)
|
||||
_watchedStepId = null;
|
||||
var cts = _refreshCts;
|
||||
if (cts is null) return;
|
||||
_refreshCts = null;
|
||||
@@ -505,6 +534,104 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
try { cts.Cancel(); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
// ── Node actions ────────────────────────────────────────────────────────
|
||||
|
||||
private Task OnRetryStepAsync(FlowStep step) => ActOnStepAsync(step, retry: true);
|
||||
|
||||
private Task OnDiscardStepAsync(FlowStep step) => ActOnStepAsync(step, retry: false);
|
||||
|
||||
/// <summary>
|
||||
/// Retry / Discard a Parked outbound node: site-scope re-check → confirm → relay through the
|
||||
/// service → toast → re-poll Replay until the node leaves Parked. Mirrors the Site Calls /
|
||||
/// Notification report pages' own action paths (same guards, same wording).
|
||||
/// </summary>
|
||||
private async Task ActOnStepAsync(FlowStep step, bool retry)
|
||||
{
|
||||
if (_actionInProgress || step.Outcome != FlowStepOutcome.Parked || step.CorrelationId is not { } corr)
|
||||
return;
|
||||
|
||||
if (!await IsStepSiteAllowedAsync(step))
|
||||
{
|
||||
_toast.ShowError("You are not permitted to act on cached calls for this site.");
|
||||
return;
|
||||
}
|
||||
|
||||
var isNotify = step.Kind == FlowStepKind.Notify;
|
||||
var shortId = corr.ToString("N")[..12];
|
||||
var noun = isNotify ? "notification" : "cached call";
|
||||
var confirmed = retry
|
||||
? await Dialog.ConfirmAsync(
|
||||
isNotify ? "Retry notification" : "Retry cached call",
|
||||
isNotify
|
||||
? $"Re-queue notification {shortId} (\"{step.Title}\") for delivery?"
|
||||
: $"Relay a retry of cached call {shortId} (\"{step.Title}\") to site {step.SiteId}?")
|
||||
: await Dialog.ConfirmAsync(
|
||||
isNotify ? "Discard notification" : "Discard cached call",
|
||||
isNotify
|
||||
? $"Permanently discard notification {shortId} (\"{step.Title}\")? This cannot be undone."
|
||||
: $"Relay a discard of {noun} {shortId} (\"{step.Title}\") to site {step.SiteId}? This cannot be undone.",
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed)
|
||||
return;
|
||||
|
||||
_actionInProgress = true;
|
||||
FlowOutboundActionResult result;
|
||||
try
|
||||
{
|
||||
result = retry
|
||||
? await FlowDebug.RetryOutboundAsync(step)
|
||||
: await FlowDebug.DiscardOutboundAsync(step);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new FlowOutboundActionResult(false, (retry ? "Retry failed: " : "Discard failed: ") + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_toast.ShowSuccess(result.Message);
|
||||
_watchedStepId = step.EventId;
|
||||
StartRefreshLoop();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError(result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side re-check before relaying — the action must not fire for a site outside the caller's
|
||||
/// permitted set even though the node is on screen. Notifications are central-owned (no site).
|
||||
/// A site the page cannot resolve is allowed through, as on the Site Calls page.
|
||||
/// </summary>
|
||||
private async Task<bool> IsStepSiteAllowedAsync(FlowStep step)
|
||||
{
|
||||
if (step.Kind == FlowStepKind.Notify || string.IsNullOrEmpty(step.SiteId))
|
||||
return true;
|
||||
if (await SiteScope.IsSystemWideAsync())
|
||||
return true;
|
||||
var site = _sites.FirstOrDefault(s => s.SiteIdentifier == step.SiteId);
|
||||
if (site is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
site = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == step.SiteId);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
site = null;
|
||||
}
|
||||
}
|
||||
if (site is null)
|
||||
return true;
|
||||
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => CancelPendingRefresh();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user