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
This commit is contained in:
@@ -8,7 +8,10 @@
|
|||||||
Attribute writes · Timing plus a "Where this ran" column with drill-ins.
|
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
|
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
|
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. *@
|
||||||
|
|
||||||
<div class="card" data-test="flow-step-detail">
|
<div class="card" data-test="flow-step-detail">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -108,7 +111,16 @@
|
|||||||
</table>
|
</table>
|
||||||
@if (Step.Outcome == FlowStepOutcome.Parked)
|
@if (Step.Outcome == FlowStepOutcome.Parked)
|
||||||
{
|
{
|
||||||
<div class="text-muted small mt-2" data-test="flow-step-parked-hint">Retry or discard from Site Calls / Notification report.</div>
|
<div class="text-muted small mt-2" data-test="flow-step-parked-hint">
|
||||||
|
@if (ReportHref is { } hintHref)
|
||||||
|
{
|
||||||
|
<text>Parked — retry or discard here, or <a href="@hintHref">open the report page</a>.</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>Parked — retry or discard here, or open the Site Calls / Notification report page.</text>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -150,6 +162,23 @@
|
|||||||
<a class="btn btn-outline-secondary btn-sm" data-test="open-audit-rows"
|
<a class="btn btn-outline-secondary btn-sm" data-test="open-audit-rows"
|
||||||
href="@($"/audit/log?executionId={exec}")">Audit rows (@AuditRowCount)</a>
|
href="@($"/audit/log?executionId={exec}")">Audit rows (@AuditRowCount)</a>
|
||||||
}
|
}
|
||||||
|
@if (ReportHref is { } rh)
|
||||||
|
{
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" data-test="open-report" href="@rh">@ReportLabel</a>
|
||||||
|
}
|
||||||
|
@if (ShowActions && (OnRetry.HasDelegate || OnDiscard.HasDelegate))
|
||||||
|
{
|
||||||
|
@if (OnRetry.HasDelegate)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-test="flow-step-retry"
|
||||||
|
disabled="@ActionInProgress" @onclick="() => OnRetry.InvokeAsync(Step!)">Retry</button>
|
||||||
|
}
|
||||||
|
@if (OnDiscard.HasDelegate)
|
||||||
|
{
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm" data-test="flow-step-discard"
|
||||||
|
disabled="@ActionInProgress" @onclick="() => OnDiscard.InvokeAsync(Step!)">Discard</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,10 +202,38 @@
|
|||||||
/// <summary>Instance database id for the Debug View drill-in (known for value-change entries).</summary>
|
/// <summary>Instance database id for the Debug View drill-in (known for value-change entries).</summary>
|
||||||
[Parameter] public int? InstanceDbId { get; set; }
|
[Parameter] public int? InstanceDbId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Retry a parked outbound step (follow-ups plan 2026-09-06 D5). No delegate = no Retry button.</summary>
|
||||||
|
[Parameter] public EventCallback<FlowStep> OnRetry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Discard a parked outbound step (follow-ups plan 2026-09-06 D5). No delegate = no Discard button.</summary>
|
||||||
|
[Parameter] public EventCallback<FlowStep> OnDiscard { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True while the owner is relaying a Retry / Discard — disables both buttons.</summary>
|
||||||
|
[Parameter] public bool ActionInProgress { get; set; }
|
||||||
|
|
||||||
private string _tab = "Input";
|
private string _tab = "Input";
|
||||||
|
|
||||||
private bool IsOutbound => Step is { } s && FlowStepProjector.IsOutbound(s.Kind);
|
private bool IsOutbound => Step is { } s && FlowStepProjector.IsOutbound(s.Kind);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <summary>Retry / Discard apply only to a parked outbound operation the report pages can address by id.</summary>
|
||||||
|
private bool ShowActions
|
||||||
|
=> Step is { Outcome: FlowStepOutcome.Parked, CorrelationId: not null } && IsOutbound;
|
||||||
|
|
||||||
/// <summary>Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab.</summary>
|
/// <summary>Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab.</summary>
|
||||||
private IEnumerable<string> VisibleTabs
|
private IEnumerable<string> VisibleTabs
|
||||||
=> Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs;
|
=> Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs;
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab,
|
/// 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class FlowStepDetailTests : BunitContext
|
public class FlowStepDetailTests : BunitContext
|
||||||
{
|
{
|
||||||
private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero);
|
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<FlowLifecycleRow>? lifecycle = null)
|
private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList<FlowLifecycleRow>? lifecycle = null, Guid? correlationId = null)
|
||||||
=> new(Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, target, "Delivered · cached", T0, 4100, outcome,
|
=> 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<FlowLifecycleRow> CachedLifecycle(string terminalStatus) =>
|
private static IReadOnlyList<FlowLifecycleRow> CachedLifecycle(string terminalStatus) =>
|
||||||
[
|
[
|
||||||
@@ -91,4 +92,94 @@ public class FlowStepDetailTests : BunitContext
|
|||||||
Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent);
|
Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent);
|
||||||
Assert.Equal("Pending", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim());
|
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<FlowStepDetail>(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<FlowStepDetail>(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<FlowStepDetail>(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<FlowStepDetail>(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<FlowStepDetail>(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<FlowStepDetail>(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]"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user