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:
Joseph Doherty
2026-09-06 11:08:05 -04:00
parent 33f9c97030
commit 7f557b0ee5
5 changed files with 277 additions and 60 deletions
@@ -9,6 +9,8 @@ using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
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.Sites;
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 ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
private readonly IDialogService _dialog = Substitute.For<IDialogService>();
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);
}
private void RegisterServices(string role = "Deployer")
private void RegisterServices(string role = "Deployer", IEnumerable<Claim>? extraClaims = null)
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -85,9 +88,12 @@ public class FlowDebuggerPageTests : BunitContext
Services.AddSingleton(_inboundApi);
Services.AddSingleton(_templates);
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(
[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)));
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
@@ -210,6 +216,133 @@ public class FlowDebuggerPageTests : BunitContext
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 ----------
/// <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']"));
}
// -----------------------------------------------------------------
// 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)
// -----------------------------------------------------------------