From 9c31f701054ab8c7cde55d592164a3bee5e81792 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 6 Sep 2026 11:01:08 -0400 Subject: [PATCH] feat(central-ui): Flow Debugger step detail links to the report page and exposes Retry/Discard on a parked node Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29 --- .../Deployment/FlowStepDetail.razor | 61 +++++++++++- .../Deployment/FlowStepDetailTests.cs | 97 ++++++++++++++++++- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor index 87f4ca57..cb9bbcc8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor @@ -8,7 +8,10 @@ Attribute writes · Timing plus a "Where this ran" column with drill-ins. Outbound-calls addendum (plan 2026-09-06 D3, Task 8): a Lifecycle tab for folded API / DB / notification operations, kind-specific Input labels, and the - System / Connection / List + "Delivered by" facts. *@ + System / Connection / List + "Delivered by" facts. + Follow-ups (plan 2026-09-06 D2/D5, Task 6): an "Open in …" deep link to the report page + for a correlated outbound step, and Retry / Discard buttons on a Parked one when the owner + supplies the callbacks. *@
@@ -108,7 +111,16 @@ @if (Step.Outcome == FlowStepOutcome.Parked) { -
Retry or discard from Site Calls / Notification report.
+
+ @if (ReportHref is { } hintHref) + { + Parked — retry or discard here, or open the report page. + } + else + { + Parked — retry or discard here, or open the Site Calls / Notification report page. + } +
} } break; @@ -150,6 +162,23 @@ Audit rows (@AuditRowCount) } + @if (ReportHref is { } rh) + { + @ReportLabel + } + @if (ShowActions && (OnRetry.HasDelegate || OnDiscard.HasDelegate)) + { + @if (OnRetry.HasDelegate) + { + + } + @if (OnDiscard.HasDelegate) + { + + } + }
@@ -173,10 +202,38 @@ /// Instance database id for the Debug View drill-in (known for value-change entries). [Parameter] public int? InstanceDbId { get; set; } + /// Retry a parked outbound step (follow-ups plan 2026-09-06 D5). No delegate = no Retry button. + [Parameter] public EventCallback OnRetry { get; set; } + + /// Discard a parked outbound step (follow-ups plan 2026-09-06 D5). No delegate = no Discard button. + [Parameter] public EventCallback OnDiscard { get; set; } + + /// True while the owner is relaying a Retry / Discard — disables both buttons. + [Parameter] public bool ActionInProgress { get; set; } + private string _tab = "Input"; private bool IsOutbound => Step is { } s && FlowStepProjector.IsOutbound(s.Kind); + /// + /// Deep link to the report page that owns the operation (D2): the Notification report for a + /// notification, Site Calls for a cached API / DB call. Null for sync one-shots (no correlation id) + /// and for steps without a folded lifecycle. + /// + private string? ReportHref + => Step is { CorrelationId: { } c } s && IsOutbound && s.Lifecycle is { Count: > 0 } + ? s.Kind == FlowStepKind.Notify + ? $"/notifications/report?correlationId={c:D}" + : $"/site-calls/report?correlationId={c:D}" + : null; + + private string ReportLabel + => Step?.Kind == FlowStepKind.Notify ? "Open in Notification report" : "Open in Site Calls"; + + /// Retry / Discard apply only to a parked outbound operation the report pages can address by id. + private bool ShowActions + => Step is { Outcome: FlowStepOutcome.Parked, CorrelationId: not null } && IsOutbound; + /// Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab. private IEnumerable VisibleTabs => Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs index bbf9ec71..ed41976a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs @@ -6,15 +6,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// /// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab, -/// outbound "Where this ran" fields and kind-specific Input tab labels. +/// outbound "Where this ran" fields and kind-specific Input tab labels. Follow-ups plan (2026-09-06) +/// Task 6: report deep links from a correlated outbound step and Retry/Discard on a parked one (D2/D5). /// public class FlowStepDetailTests : BunitContext { private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero); - private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList? lifecycle = null) + private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList? lifecycle = null, Guid? correlationId = null) => new(Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, target, "Delivered · cached", T0, 4100, outcome, - "{\"id\":1}", "{\"ok\":true}", outcome == FlowStepOutcome.Failed ? "boom" : null, "site-a", "node-a", "M1", target, null, lifecycle); + "{\"id\":1}", "{\"ok\":true}", outcome == FlowStepOutcome.Failed ? "boom" : null, "site-a", "node-a", "M1", target, null, lifecycle, correlationId); private static IReadOnlyList CachedLifecycle(string terminalStatus) => [ @@ -91,4 +92,94 @@ public class FlowStepDetailTests : BunitContext Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent); Assert.Equal("Pending", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim()); } + [Fact] + public void OutboundStepWithCorrelation_LinksToTheReportPage() + { + var c = Guid.NewGuid(); + + var api = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered"), c); + var cut = Render(p => p.Add(x => x.Step, api)); + var link = cut.Find("a[data-test=open-report]"); + Assert.Equal($"/site-calls/report?correlationId={c:D}", link.GetAttribute("href")); + Assert.Equal("Open in Site Calls", link.TextContent.Trim()); + + var notify = Step(FlowStepKind.Notify, FlowStepOutcome.Ok, "Planners", CachedLifecycle("Delivered"), c); + cut = Render(p => p.Add(x => x.Step, notify)); + link = cut.Find("a[data-test=open-report]"); + Assert.Equal($"/notifications/report?correlationId={c:D}", link.GetAttribute("href")); + Assert.Equal("Open in Notification report", link.TextContent.Trim()); + + var sync = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe"); + cut = Render(p => p.Add(x => x.Step, sync)); + Assert.Empty(cut.FindAll("[data-test=open-report]")); + } + + [Fact] + public void ParkedStep_WithCallbacks_ShowsRetryAndDiscard() + { + var c = Guid.NewGuid(); + var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Parked, "Delmia.AckRecipe", CachedLifecycle("Parked"), c); + FlowStep? retried = null; + FlowStep? discarded = null; + + var cut = Render(p => p + .Add(x => x.Step, step) + .Add(x => x.OnRetry, s => retried = s) + .Add(x => x.OnDiscard, s => discarded = s)); + + var retry = cut.Find("button[data-test=flow-step-retry]"); + var discard = cut.Find("button[data-test=flow-step-discard]"); + Assert.Equal("Retry", retry.TextContent.Trim()); + Assert.Equal("Discard", discard.TextContent.Trim()); + Assert.Contains("btn-outline-primary", retry.ClassName); + Assert.Contains("btn-outline-danger", discard.ClassName); + Assert.False(retry.HasAttribute("disabled")); + Assert.False(discard.HasAttribute("disabled")); + + retry.Click(); + Assert.Same(step, retried); + Assert.Null(discarded); + + discard.Click(); + Assert.Same(step, discarded); + + cut.Render(p => p + .Add(x => x.Step, step) + .Add(x => x.OnRetry, s => retried = s) + .Add(x => x.OnDiscard, s => discarded = s) + .Add(x => x.ActionInProgress, true)); + Assert.True(cut.Find("button[data-test=flow-step-retry]").HasAttribute("disabled")); + Assert.True(cut.Find("button[data-test=flow-step-discard]").HasAttribute("disabled")); + } + + [Fact] + public void ParkedStep_WithoutCallbacks_ShowsOnlyTheHintLink() + { + var c = Guid.NewGuid(); + var step = Step(FlowStepKind.Notify, FlowStepOutcome.Parked, "Planners", CachedLifecycle("Parked"), c); + var cut = Render(p => p.Add(x => x.Step, step)); + + Assert.Empty(cut.FindAll("[data-test=flow-step-retry]")); + Assert.Empty(cut.FindAll("[data-test=flow-step-discard]")); + + cut.Find("[data-test=flow-step-tab-lifecycle]").Click(); + var hint = cut.Find("[data-test=flow-step-parked-hint]"); + Assert.Contains("Parked", hint.TextContent); + var hintLink = hint.QuerySelector("a"); + Assert.NotNull(hintLink); + Assert.Equal($"/notifications/report?correlationId={c:D}", hintLink!.GetAttribute("href")); + } + + [Fact] + public void OkStep_NeverShowsActions() + { + var step = Step(FlowStepKind.ApiCall, FlowStepOutcome.Ok, "Delmia.AckRecipe", CachedLifecycle("Delivered"), Guid.NewGuid()); + var cut = Render(p => p + .Add(x => x.Step, step) + .Add(x => x.OnRetry, _ => { }) + .Add(x => x.OnDiscard, _ => { })); + + Assert.Empty(cut.FindAll("[data-test=flow-step-retry]")); + Assert.Empty(cut.FindAll("[data-test=flow-step-discard]")); + } }