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,7 +9,7 @@
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"subject": "Task 1: FlowStep.CorrelationId + CLI column",
|
"subject": "Task 1: FlowStep.CorrelationId + CLI column",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"subject": "Task 2: Site Calls query \u2014 filter by TrackedOperationId",
|
"subject": "Task 2: Site Calls query \u2014 filter by TrackedOperationId",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"subject": "Task 3: Notification Outbox query \u2014 filter by NotificationId",
|
"subject": "Task 3: Notification Outbox query \u2014 filter by NotificationId",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"subject": "Task 4: Site Calls page accepts ?correlationId=",
|
"subject": "Task 4: Site Calls page accepts ?correlationId=",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
2
|
2
|
||||||
]
|
]
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"subject": "Task 5: Notification report page accepts ?correlationId=",
|
"subject": "Task 5: Notification report page accepts ?correlationId=",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
3
|
3
|
||||||
]
|
]
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
"subject": "Task 6: FlowStepDetail \u2014 report deep links + Retry/Discard controls",
|
"subject": "Task 6: FlowStepDetail \u2014 report deep links + Retry/Discard controls",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
1
|
1
|
||||||
]
|
]
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"subject": "Task 7: IFlowDebugService.RetryOutboundAsync / DiscardOutboundAsync",
|
"subject": "Task 7: IFlowDebugService.RetryOutboundAsync / DiscardOutboundAsync",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
1
|
1
|
||||||
]
|
]
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
{
|
{
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"subject": "Task 8: Flow Debugger page \u2014 wire actions, confirm, scope check, re-poll",
|
"subject": "Task 8: Flow Debugger page \u2014 wire actions, confirm, scope check, re-poll",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
6,
|
6,
|
||||||
7
|
7
|
||||||
@@ -90,5 +90,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-09-06T15:12:00Z"
|
"lastUpdated": "2026-09-06T16:05:00Z"
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<h4 class="mb-3">Flow Debugger</h4>
|
<h4 class="mb-3">Flow Debugger</h4>
|
||||||
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
@* ── Toolbar ─────────────────────────────────────────────────────────── *@
|
@* ── Toolbar ─────────────────────────────────────────────────────────── *@
|
||||||
<div class="row g-2 align-items-end mb-2" data-test="flow-toolbar">
|
<div class="row g-2 align-items-end mb-2" data-test="flow-toolbar">
|
||||||
@@ -233,7 +234,8 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<FlowStepDetail Step="_selected" Graph="_graph"
|
<FlowStepDetail Step="_selected" Graph="_graph"
|
||||||
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+130
-3
@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
using Microsoft.JSInterop;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
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.CentralUI.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
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 ISiteRepository SiteRepository { get; set; } = default!;
|
||||||
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
||||||
[Inject] private IJSRuntime JS { 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>
|
/// <summary>Drill-in: pre-select an inbound method.</summary>
|
||||||
[SupplyParameterFromQuery] public int? MethodId { get; set; }
|
[SupplyParameterFromQuery] public int? MethodId { get; set; }
|
||||||
@@ -124,11 +126,23 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
private CancellationTokenSource? _refreshCts;
|
private CancellationTokenSource? _refreshCts;
|
||||||
private bool _refreshing;
|
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>
|
/// <summary>Delay seam for the pending-refresh loop (tests inject an immediate delay).</summary>
|
||||||
internal Func<TimeSpan, CancellationToken, Task> Delay { get; set; } = Task.Delay;
|
internal Func<TimeSpan, CancellationToken, Task> Delay { get; set; } = Task.Delay;
|
||||||
|
|
||||||
private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0;
|
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 CanRefresh => !_running && _raw is not null;
|
||||||
|
|
||||||
private bool CanRun => !_running && _mode == FlowMode.Live && (
|
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>
|
/// <summary>Begins polling when a completed Live run left outbound work in flight. Never in Replay mode.</summary>
|
||||||
private void StartPendingRefresh()
|
private void StartPendingRefresh()
|
||||||
{
|
{
|
||||||
if (_mode != FlowMode.Live || _raw is null || _graph is null || PendingCount == 0)
|
if (_mode != FlowMode.Live || PendingCount == 0)
|
||||||
return;
|
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();
|
var cts = new CancellationTokenSource();
|
||||||
_refreshCts = cts;
|
_refreshCts = cts;
|
||||||
_ = RefreshPendingLoopAsync(_raw.RootExecutionId, cts);
|
_ = RefreshPendingLoopAsync(_raw.RootExecutionId, cts);
|
||||||
@@ -438,7 +464,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
if (ct.IsCancellationRequested) return;
|
if (ct.IsCancellationRequested) return;
|
||||||
MergeRefreshedGraph(outcome);
|
MergeRefreshedGraph(outcome);
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
if (PendingCount == 0) return;
|
if (!ShouldKeepPolling) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -455,6 +481,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
{
|
{
|
||||||
_refreshing = false;
|
_refreshing = false;
|
||||||
_refreshCts = null;
|
_refreshCts = null;
|
||||||
|
_watchedStepId = null;
|
||||||
cts.Dispose();
|
cts.Dispose();
|
||||||
try { await InvokeAsync(StateHasChanged); } catch (ObjectDisposedException) { }
|
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;
|
_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;
|
var cts = _refreshCts;
|
||||||
if (cts is null) return;
|
if (cts is null) return;
|
||||||
_refreshCts = null;
|
_refreshCts = null;
|
||||||
@@ -505,6 +534,104 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
try { cts.Cancel(); } catch (ObjectDisposedException) { }
|
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/>
|
/// <inheritdoc/>
|
||||||
public void Dispose() => CancelPendingRefresh();
|
public void Dispose() => CancelPendingRefresh();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ using NSubstitute;
|
|||||||
using ZB.MOM.WW.Audit;
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
@@ -32,6 +34,7 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
|
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
|
||||||
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
|
||||||
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
|
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
|
||||||
|
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
|
||||||
|
|
||||||
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;")
|
private static readonly ApiMethod Delmia = new("DelmiaRecipeDownload", "return 1;")
|
||||||
{
|
{
|
||||||
@@ -70,7 +73,7 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
|
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterServices(string role = "Deployer")
|
private void RegisterServices(string role = "Deployer", IEnumerable<Claim>? extraClaims = null)
|
||||||
{
|
{
|
||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
|
||||||
@@ -85,9 +88,12 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
Services.AddSingleton(_inboundApi);
|
Services.AddSingleton(_inboundApi);
|
||||||
Services.AddSingleton(_templates);
|
Services.AddSingleton(_templates);
|
||||||
Services.AddSingleton(_sites);
|
Services.AddSingleton(_sites);
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DialogSeverity>(), Arg.Any<string?>()).Returns(true);
|
||||||
|
Services.AddSingleton(_dialog);
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(
|
var identity = new ClaimsIdentity(
|
||||||
[new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role)], "TestCookie");
|
[new Claim(ClaimTypes.Name, "deployer"), new Claim(ClaimTypes.Role, role), .. extraClaims ?? []], "TestCookie");
|
||||||
var stubAuth = new StubAuthStateProvider(new AuthenticationState(new ClaimsPrincipal(identity)));
|
var stubAuth = new StubAuthStateProvider(new AuthenticationState(new ClaimsPrincipal(identity)));
|
||||||
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
|
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
|
||||||
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
||||||
@@ -210,6 +216,133 @@ public class FlowDebuggerPageTests : BunitContext
|
|||||||
cut.WaitForElement("[data-test='flow-site-not-traced']");
|
cut.WaitForElement("[data-test='flow-site-not-traced']");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Node actions (follow-ups plan 2026-09-06, Task 8): Retry / Discard on a Parked node -------
|
||||||
|
|
||||||
|
/// <summary>Same events as <see cref="OutboundOutcome"/> but the cached call ends Parked; the second
|
||||||
|
/// outcome shares the event objects (stable ids, as real audit rows are) and adds a Delivered resolve.</summary>
|
||||||
|
private static (FlowDebugOutcome Parked, FlowDebugOutcome Resolved, Guid StepId) ParkedThenResolved(Guid root)
|
||||||
|
{
|
||||||
|
var child = Guid.NewGuid();
|
||||||
|
var corr = Guid.NewGuid();
|
||||||
|
var t0 = DateTime.UtcNow.AddSeconds(-10);
|
||||||
|
var submit = 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");
|
||||||
|
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),
|
||||||
|
submit,
|
||||||
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Parked,
|
||||||
|
occurredAtUtc: t0.AddSeconds(4), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||||
|
sourceSiteId: "site-a", sourceInstanceId: "Sim01", errorMessage: "503 Service Unavailable"),
|
||||||
|
};
|
||||||
|
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
|
||||||
|
var parked = new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
|
||||||
|
|
||||||
|
var later = new List<AuditEvent>(events)
|
||||||
|
{
|
||||||
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.CachedResolve, AuditStatus.Delivered,
|
||||||
|
occurredAtUtc: t0.AddSeconds(9), target: "Delmia.Ack", correlationId: corr, executionId: child, parentExecutionId: root,
|
||||||
|
sourceSiteId: "site-a", sourceInstanceId: "Sim01"),
|
||||||
|
};
|
||||||
|
var resolved = new FlowDebugOutcome(FlowGraphBuilder.Build(later), raw, null, null);
|
||||||
|
return (parked, resolved, submit.EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IRenderedComponent<FlowDebuggerPage> RunToParkedNode(Guid root, FlowDebugOutcome parked)
|
||||||
|
{
|
||||||
|
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(parked);
|
||||||
|
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()));
|
||||||
|
cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click();
|
||||||
|
cut.WaitForElement("button[data-test='flow-step-retry']");
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParkedNode_Retry_ConfirmsRelaysAndRepollsUntilNotParked()
|
||||||
|
{
|
||||||
|
RegisterServices();
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
var (parked, resolved, stepId) = ParkedThenResolved(root);
|
||||||
|
_flow.RetryOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new FlowOutboundActionResult(true, "Retry of abc relayed to site-a."));
|
||||||
|
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(parked, resolved);
|
||||||
|
var cut = RunToParkedNode(root, parked);
|
||||||
|
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()); // Parked is not Pending: no auto-poll
|
||||||
|
|
||||||
|
cut.Find("button[data-test='flow-step-retry']").Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() => _flow.Received(1).RetryOutboundAsync(Arg.Is<FlowStep>(s => s.EventId == stepId), Arg.Any<CancellationToken>()));
|
||||||
|
_dialog.Received(1).ConfirmAsync("Retry cached call", Arg.Is<string>(m => m.Contains("Delmia.Ack")), Arg.Any<bool>(), Arg.Any<string?>());
|
||||||
|
cut.WaitForAssertion(() => _flow.Received(2).ReplayAsync(root, Arg.Any<CancellationToken>()));
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok"));
|
||||||
|
Assert.Contains("relayed", cut.Markup);
|
||||||
|
Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']")); // the selected node is no longer parked
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParkedNode_Discard_UsesDangerConfirm_AndDoesNotPollWhenRefused()
|
||||||
|
{
|
||||||
|
RegisterServices();
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
var (parked, _, _) = ParkedThenResolved(root);
|
||||||
|
_flow.DiscardOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new FlowOutboundActionResult(false, "Site unreachable — the relay did not reach the owning site."));
|
||||||
|
var cut = RunToParkedNode(root, parked);
|
||||||
|
|
||||||
|
cut.Find("button[data-test='flow-step-discard']").Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() => _flow.Received(1).DiscardOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>()));
|
||||||
|
_dialog.Received(1).ConfirmAsync("Discard cached call", Arg.Is<string>(m => m.Contains("cannot be undone")), true, "Discard");
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("Site unreachable", cut.Markup));
|
||||||
|
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||||
|
Assert.NotNull(cut.Find("button[data-test='flow-step-retry']"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParkedNode_ConfirmDeclined_NeverRelays()
|
||||||
|
{
|
||||||
|
RegisterServices();
|
||||||
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(false);
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
var (parked, _, _) = ParkedThenResolved(root);
|
||||||
|
var cut = RunToParkedNode(root, parked);
|
||||||
|
|
||||||
|
cut.Find("button[data-test='flow-step-retry']").Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() => _dialog.Received(1).ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()));
|
||||||
|
_flow.DidNotReceive().RetryOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>());
|
||||||
|
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParkedNode_OutOfScopeSite_RefusedBeforeConfirm()
|
||||||
|
{
|
||||||
|
// Deployer scoped to site 2 only; the parked call belongs to site-a (id 1).
|
||||||
|
RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "2")]);
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
var (parked, _, _) = ParkedThenResolved(root);
|
||||||
|
var cut = RunToParkedNode(root, parked);
|
||||||
|
|
||||||
|
cut.Find("button[data-test='flow-step-retry']").Click();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("not permitted", cut.Markup));
|
||||||
|
_dialog.DidNotReceive().ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>());
|
||||||
|
_flow.DidNotReceive().RetryOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
|
// ---- 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>
|
/// <summary>Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>.</summary>
|
||||||
|
|||||||
@@ -151,51 +151,6 @@ public sealed class QueryStringDrillInTests
|
|||||||
Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"));
|
Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// SiteCallsReport — ?correlationId= (Flow Debugger / Audit Log deep link, D2)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilter()
|
|
||||||
{
|
|
||||||
// A well-formed GUID rides on the first query as the exact-match
|
|
||||||
// TrackedOperationIdFilter and renders the read-only Operation pill.
|
|
||||||
var op = Guid.Parse("44444444-4444-4444-4444-444444444444");
|
|
||||||
using var ctx = new SiteCallsReportFixture();
|
|
||||||
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
|
|
||||||
nav.NavigateTo($"/site-calls/report?correlationId={op}");
|
|
||||||
|
|
||||||
var cut = ctx.Render<SiteCallsReportPage>();
|
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
|
||||||
{
|
|
||||||
Assert.Single(ctx.QueryRequests);
|
|
||||||
Assert.Equal(op, ctx.QueryRequests[0].TrackedOperationIdFilter);
|
|
||||||
var pill = cut.Find("[data-test='site-calls-filter-operation']");
|
|
||||||
Assert.Contains(op.ToString("N")[..12], pill.TextContent);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SiteCallsReport_CorrelationIdQuery_Malformed_IsIgnored()
|
|
||||||
{
|
|
||||||
// Lax parsing, like ?status= — a value that is not a GUID leaves the
|
|
||||||
// filter null and renders no pill, so a hand-crafted bad URL loads the
|
|
||||||
// page unfiltered instead of breaking it.
|
|
||||||
using var ctx = new SiteCallsReportFixture();
|
|
||||||
var nav = (BunitNavigationManager)ctx.Services.GetRequiredService<NavigationManager>();
|
|
||||||
nav.NavigateTo("/site-calls/report?correlationId=nope");
|
|
||||||
|
|
||||||
var cut = ctx.Render<SiteCallsReportPage>();
|
|
||||||
|
|
||||||
cut.WaitForAssertion(() =>
|
|
||||||
{
|
|
||||||
Assert.Single(ctx.QueryRequests);
|
|
||||||
Assert.Null(ctx.QueryRequests[0].TrackedOperationIdFilter);
|
|
||||||
});
|
|
||||||
Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// NotificationReport — ?correlationId= (Flow Debugger follow-ups, D2)
|
// NotificationReport — ?correlationId= (Flow Debugger follow-ups, D2)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user