fix(centralui): confirm dialogs name their own action instead of always saying "Delete" (register row 28)
The Health dashboard's "Trigger failover" confirmation offered a red button
labelled *Delete*. The copy was right; the button was not. `DialogHost` derived
the confirm label solely from `danger`, so `danger: true` meant the verb was
literally "Delete" for every caller — and no caller could say otherwise, because
`IDialogService.ConfirmAsync` had no label parameter.
`danger` now selects styling only; a new optional `confirmLabel` names the action
and wins over the fallback. The kind/danger fallback is deliberately RETAINED
("Save" for a prompt, "Delete" for a bare danger confirm, "Confirm" otherwise):
around 20 call sites are genuine deletes where it is correct, and the Playwright
suites click the literal "Delete". Requiring the label everywhere would have
churned those 20 sites for no behaviour change, with the miss-detection resting
on Playwright runs that need a live rig.
Audited all 34 confirm call sites; 9 read "Delete" for something that is not a
delete and are relabelled:
Fail over CentralFailoverControl (central pair, site pair)
Approve SecuredWrites — approve (this one WRITES TO A DEVICE)
Reject SecuredWrites — reject
Disable Topology — disable instance
Discard SiteCallsReport, NotificationReport, ParkedMessages (bulk + single)
The remaining 25 are correct as they stand: 18 genuine deletes plus 7 non-danger
confirms ("Revert to base", the three Retry paths) whose button reads "Confirm".
Tests: FailoverConfirmLabelTests drives the real DialogService behind a real
DialogHost — a substitute would pin the argument but not what the operator reads
— covering both branches, that the button keeps btn-danger, and that an
unlabelled danger confirm still falls back to "Delete". Note its click is
deliberately not awaited: with a real dialog the handler parks on ConfirmAsync
until answered, so awaiting deadlocks. Existing IDialogService fakes and
NSubstitute matchers gain the 4th parameter (an optional param is baked into the
call site, so a 3-arg Arg.Any stub silently stops matching a labelled call).
This commit is contained in:
@@ -13,6 +13,7 @@ Central cluster only. Sites have no user interface.
|
|||||||
- **Framework**: Blazor Server (ASP.NET Core). UI logic executes on the server, updates pushed to the browser via SignalR.
|
- **Framework**: Blazor Server (ASP.NET Core). UI logic executes on the server, updates pushed to the browser via SignalR.
|
||||||
- Keeps the entire stack in C#/.NET, consistent with the rest of the system (Akka.NET, EF Core).
|
- Keeps the entire stack in C#/.NET, consistent with the rest of the system (Akka.NET, EF Core).
|
||||||
- SignalR provides built-in support for real-time UI updates.
|
- SignalR provides built-in support for real-time UI updates.
|
||||||
|
- **Dialog service confirm labels.** Every confirmation goes through `IDialogService.ConfirmAsync(title, message, danger, confirmLabel)`, rendered by the single `DialogHost` in `MainLayout`. `danger` selects **styling only** (`btn-danger`); `confirmLabel` names the action. When no label is supplied the button falls back to "Delete" for a danger confirm and "Confirm" otherwise — a convenience for the ~20 genuine delete call sites, and the reason the Health dashboard's *Trigger failover* dialog used to offer a red **Delete** button (deferred-work register row 28). **Any destructive action that is not literally a delete must pass `confirmLabel`**: failover → *Fail over*, secured-write approve/reject → *Approve* / *Reject*, instance disable → *Disable*, and every parked-message / notification / cached-call discard → *Discard*.
|
||||||
|
|
||||||
## Failover Behavior
|
## Failover Behavior
|
||||||
|
|
||||||
@@ -243,6 +244,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
|
|||||||
- Headline **Notification Outbox KPI tiles** — queue depth, stuck count, and parked count. These are central-computed by the Notification Outbox from the central `Notifications` table (not part of any site health report). The full outbox view is on the dedicated Notification Outbox page.
|
- Headline **Notification Outbox KPI tiles** — queue depth, stuck count, and parked count. These are central-computed by the Notification Outbox from the central `Notifications` table (not part of any site health report). The full outbox view is on the dedicated Notification Outbox page.
|
||||||
- Headline **Site Call Audit KPI tiles** — buffered count, parked count, and failed-last-interval. These are central-computed by the Site Call Audit component from the central `SiteCalls` table (not part of any site health report). The full cached-call view is on the dedicated Site Calls page.
|
- Headline **Site Call Audit KPI tiles** — buffered count, parked count, and failed-last-interval. These are central-computed by the Site Call Audit component from the central `SiteCalls` table (not part of any site health report). The full cached-call view is on the dedicated Site Calls page.
|
||||||
- Headline **Audit KPI tiles** — three tiles in a new "Audit" KPI group: **Audit volume**, **Audit error rate**, and **Audit backlog**. These are sourced from the Audit Log component (#23) and Health Monitoring per the metric definitions in Component-HealthMonitoring.md; the dashboard simply surfaces them. The full audit query view is on the dedicated Audit Log page.
|
- Headline **Audit KPI tiles** — three tiles in a new "Audit" KPI group: **Audit volume**, **Audit error rate**, and **Audit backlog**. These are sourced from the Audit Log component (#23) and Health Monitoring per the metric definitions in Component-HealthMonitoring.md; the dashboard simply surfaces them. The full audit query view is on the dedicated Audit Log page.
|
||||||
|
- **Trigger failover** (`CentralFailoverControl`, Administrator only) — on the central card and on each site card. Disabled with an explanatory tooltip when there is no standby to take over. It always confirms first, and the confirmation's red button reads **Fail over** (see the dialog-label contract under Technology); the central variant's copy states that this page is served by the node being restarted and will disconnect and reconnect, while the site variant deliberately does not, since a site is a different cluster.
|
||||||
- **Shared KPI cache (arch-review WP2.4).** All four KPI families above — Notification Outbox, Site Call Audit, Audit, and their per-site/per-node breakdowns — are read through a process-level `IKpiSnapshotCache`, not queried per page load. Each accessor is independently memoized (8s TTL) with single-flight production, so N Blazor circuits polling the same KPI inside one window (e.g. ten operators with the Health dashboard open) cost the node ONE aggregate SQL round trip per KPI per window, not N. A failed query is never memoized — the next caller re-attempts it, and the calling page's existing per-tile "unavailable" degradation is unchanged. Every KPI-tile page (Health, Notification Outbox, Site Calls, Audit Log) shares the same cache instance.
|
- **Shared KPI cache (arch-review WP2.4).** All four KPI families above — Notification Outbox, Site Call Audit, Audit, and their per-site/per-node breakdowns — are read through a process-level `IKpiSnapshotCache`, not queried per page load. Each accessor is independently memoized (8s TTL) with single-flight production, so N Blazor circuits polling the same KPI inside one window (e.g. ten operators with the Health dashboard open) cost the node ONE aggregate SQL round trip per KPI per window, not N. A failed query is never memoized — the next caller re-attempts it, and the calling page's existing per-tile "unavailable" degradation is unchanged. Every KPI-tile page (Health, Notification Outbox, Site Calls, Audit Log) shares the same cache instance.
|
||||||
|
|
||||||
### Site Event Log Viewer (Deployment Role)
|
### Site Event Log Viewer (Deployment Role)
|
||||||
|
|||||||
@@ -70,14 +70,16 @@
|
|||||||
+ "standby takes over and its Deployment Manager singleton hands over gracefully. "
|
+ "standby takes over and its Deployment Manager singleton hands over gracefully. "
|
||||||
+ "In-flight work on that site node is interrupted, and the site is briefly "
|
+ "In-flight work on that site node is interrupted, and the site is briefly "
|
||||||
+ "unavailable while the handover completes.",
|
+ "unavailable while the handover completes.",
|
||||||
danger: true)
|
danger: true,
|
||||||
|
confirmLabel: "Fail over")
|
||||||
: await Dialog.ConfirmAsync(
|
: await Dialog.ConfirmAsync(
|
||||||
"Trigger central failover?",
|
"Trigger central failover?",
|
||||||
"The active central node will leave the cluster and restart; the standby takes over "
|
"The active central node will leave the cluster and restart; the standby takes over "
|
||||||
+ "and becomes active. Cluster singletons hand over gracefully, but in-flight work on "
|
+ "and becomes active. Cluster singletons hand over gracefully, but in-flight work on "
|
||||||
+ "the active node is interrupted. This page is served by the active node, so it will "
|
+ "the active node is interrupted. This page is served by the active node, so it will "
|
||||||
+ "briefly disconnect and reconnect against the new active node.",
|
+ "briefly disconnect and reconnect against the new active node.",
|
||||||
danger: true);
|
danger: true,
|
||||||
|
confirmLabel: "Fail over");
|
||||||
|
|
||||||
if (!confirmed)
|
if (!confirmed)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -868,7 +868,8 @@
|
|||||||
var confirmed = await Dialog.ConfirmAsync(
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
"Disable Instance",
|
"Disable Instance",
|
||||||
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
||||||
danger: true);
|
danger: true,
|
||||||
|
confirmLabel: "Disable");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
|
|||||||
+4
-2
@@ -668,7 +668,8 @@
|
|||||||
var confirmed = await Dialog.ConfirmAsync(
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
"Discard parked messages",
|
"Discard parked messages",
|
||||||
$"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.",
|
$"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.",
|
||||||
danger: true);
|
danger: true,
|
||||||
|
confirmLabel: "Discard");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
_bulkInProgress = true;
|
_bulkInProgress = true;
|
||||||
@@ -720,7 +721,8 @@
|
|||||||
var confirmed = await Dialog.ConfirmAsync(
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
"Discard parked message",
|
"Discard parked message",
|
||||||
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
|
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
|
||||||
danger: true);
|
danger: true,
|
||||||
|
confirmLabel: "Discard");
|
||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
|
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
|
|||||||
+2
-1
@@ -566,7 +566,8 @@
|
|||||||
var confirmed = await Dialog.ConfirmAsync(
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
"Discard notification",
|
"Discard notification",
|
||||||
$"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.",
|
$"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.",
|
||||||
danger: true);
|
danger: true,
|
||||||
|
confirmLabel: "Discard");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
|
|||||||
@@ -424,7 +424,8 @@
|
|||||||
$"Value: {row.ValueJson} ({row.ValueType})\n" +
|
$"Value: {row.ValueJson} ({row.ValueType})\n" +
|
||||||
$"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" +
|
$"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" +
|
||||||
$"Operator: {row.OperatorUser}",
|
$"Operator: {row.OperatorUser}",
|
||||||
danger: true);
|
danger: true,
|
||||||
|
confirmLabel: "Approve");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
@@ -460,7 +461,8 @@
|
|||||||
var confirmed = await Dialog.ConfirmAsync(
|
var confirmed = await Dialog.ConfirmAsync(
|
||||||
"Reject secured write",
|
"Reject secured write",
|
||||||
$"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?",
|
$"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?",
|
||||||
danger: true);
|
danger: true,
|
||||||
|
confirmLabel: "Reject");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
|
|||||||
+2
-1
@@ -343,7 +343,8 @@ public partial class SiteCallsReport
|
|||||||
"Discard cached call",
|
"Discard cached call",
|
||||||
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||||
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
|
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
|
||||||
danger: true);
|
danger: true,
|
||||||
|
confirmLabel: "Discard");
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
_actionInProgress = true;
|
_actionInProgress = true;
|
||||||
|
|||||||
@@ -185,9 +185,17 @@
|
|||||||
svc.Resolve(resolveValue);
|
svc.Resolve(resolveValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ConfirmLabel(DialogState state) => state.Kind switch
|
// An explicit label from the caller always wins. The kind/danger fallback below
|
||||||
{
|
// is a convenience for the common cases only: a bare danger confirm is assumed to
|
||||||
DialogKind.Prompt => "Save",
|
// be a delete, which is exactly the assumption that put "Delete" on the Trigger
|
||||||
_ => state.Danger ? "Delete" : "Confirm",
|
// failover button (deferred-work register row 28). Callers whose destructive verb
|
||||||
};
|
// is not "Delete" must pass ConfirmLabel — see IDialogService.ConfirmAsync.
|
||||||
|
private static string ConfirmLabel(DialogState state) =>
|
||||||
|
!string.IsNullOrWhiteSpace(state.ConfirmLabel)
|
||||||
|
? state.ConfirmLabel
|
||||||
|
: state.Kind switch
|
||||||
|
{
|
||||||
|
DialogKind.Prompt => "Save",
|
||||||
|
_ => state.Danger ? "Delete" : "Confirm",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,12 +35,19 @@ public class DialogService : IDialogService
|
|||||||
private TaskCompletionSource<object?>? _tcs;
|
private TaskCompletionSource<object?>? _tcs;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
|
||||||
{
|
{
|
||||||
EnsureNoActiveDialog();
|
EnsureNoActiveDialog();
|
||||||
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_tcs = tcs;
|
_tcs = tcs;
|
||||||
Current = new DialogState(title, DialogKind.Confirm, message, danger, PromptInitial: string.Empty, Placeholder: null);
|
Current = new DialogState(
|
||||||
|
title,
|
||||||
|
DialogKind.Confirm,
|
||||||
|
message,
|
||||||
|
danger,
|
||||||
|
PromptInitial: string.Empty,
|
||||||
|
Placeholder: null,
|
||||||
|
ConfirmLabel: confirmLabel);
|
||||||
OnChange?.Invoke();
|
OnChange?.Invoke();
|
||||||
return Project(tcs.Task, static r => r is bool b && b);
|
return Project(tcs.Task, static r => r is bool b && b);
|
||||||
}
|
}
|
||||||
@@ -146,6 +153,9 @@ public class DialogService : IDialogService
|
|||||||
/// render inside <c>.modal-body</c>; <c>null</c> for confirm/prompt.</param>
|
/// render inside <c>.modal-body</c>; <c>null</c> for confirm/prompt.</param>
|
||||||
/// <param name="Size">Optional Bootstrap modal-dialog size modifier class applied
|
/// <param name="Size">Optional Bootstrap modal-dialog size modifier class applied
|
||||||
/// to the <c>modal-dialog</c> element (e.g. <c>modal-lg</c>); <c>null</c> = default.</param>
|
/// to the <c>modal-dialog</c> element (e.g. <c>modal-lg</c>); <c>null</c> = default.</param>
|
||||||
|
/// <param name="ConfirmLabel">Explicit verb for the confirm button. <c>null</c> or
|
||||||
|
/// blank falls back to the kind/danger default ("Save" for a prompt, "Delete" for a
|
||||||
|
/// danger confirm, "Confirm" otherwise).</param>
|
||||||
public record DialogState(
|
public record DialogState(
|
||||||
string Title,
|
string Title,
|
||||||
DialogKind Kind,
|
DialogKind Kind,
|
||||||
@@ -154,7 +164,8 @@ public record DialogState(
|
|||||||
string PromptInitial,
|
string PromptInitial,
|
||||||
string? Placeholder,
|
string? Placeholder,
|
||||||
RenderFragment? Content = null,
|
RenderFragment? Content = null,
|
||||||
string? Size = null);
|
string? Size = null,
|
||||||
|
string? ConfirmLabel = null);
|
||||||
|
|
||||||
public enum DialogKind
|
public enum DialogKind
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,10 +19,17 @@ public interface IDialogService
|
|||||||
/// <param name="title">Modal title text.</param>
|
/// <param name="title">Modal title text.</param>
|
||||||
/// <param name="message">Body text shown to the user.</param>
|
/// <param name="message">Body text shown to the user.</param>
|
||||||
/// <param name="danger">When <c>true</c>, the confirm button renders in
|
/// <param name="danger">When <c>true</c>, the confirm button renders in
|
||||||
/// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary
|
/// <c>btn-danger</c> styling; otherwise a primary button is shown. This
|
||||||
/// "Confirm" button is shown.</param>
|
/// controls <em>styling only</em> — it does not name the action.</param>
|
||||||
|
/// <param name="confirmLabel">Verb rendered on the confirm button. When
|
||||||
|
/// <c>null</c> or blank the button falls back to "Delete" for a danger
|
||||||
|
/// dialog and "Confirm" otherwise. <strong>Any destructive action that is
|
||||||
|
/// not literally a delete must pass this</strong> — otherwise the red button
|
||||||
|
/// on, say, a failover or a discard reads "Delete", which is simply wrong
|
||||||
|
/// (deferred-work register row 28). See the label table in
|
||||||
|
/// <c>Component-CentralUI.md</c> → Dialog Service.</param>
|
||||||
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
|
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
|
||||||
Task<bool> ConfirmAsync(string title, string message, bool danger = false);
|
Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shows a single-line text prompt and resolves to the entered value, or
|
/// Shows a single-line text prompt and resolves to the entered value, or
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class SecuredWritesTests : BunitContext
|
|||||||
Services.AddSingleton(_browse);
|
Services.AddSingleton(_browse);
|
||||||
|
|
||||||
// Confirm dialogs resolve to "yes" without a DialogHost in the render scope.
|
// Confirm dialogs resolve to "yes" without a DialogHost in the render scope.
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>())
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>())
|
||||||
.Returns(Task.FromResult(true));
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
// Empty list by default; individual tests seed the queue/history.
|
// Empty list by default; individual tests seed the queue/history.
|
||||||
@@ -303,7 +303,8 @@ public class SecuredWritesTests : BunitContext
|
|||||||
_dialog.Received(1).ConfirmAsync(
|
_dialog.Received(1).ConfirmAsync(
|
||||||
Arg.Any<string>(),
|
Arg.Any<string>(),
|
||||||
Arg.Is<string>(m => Regex.IsMatch(m, "Submitted .* ago")),
|
Arg.Is<string>(m => Regex.IsMatch(m, "Submitted .* ago")),
|
||||||
Arg.Any<bool>());
|
Arg.Any<bool>(),
|
||||||
|
Arg.Any<string?>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ public class SchemaLibraryPageTests : BunitContext
|
|||||||
/// <summary>A dialog service that auto-confirms, so the delete path runs end-to-end.</summary>
|
/// <summary>A dialog service that auto-confirms, so the delete path runs end-to-end.</summary>
|
||||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||||
{
|
{
|
||||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the verb on the "Trigger failover" confirmation button (deferred-work
|
||||||
|
/// register row 28). The dialog copy was always right, but the red confirm button
|
||||||
|
/// read <em>"Delete"</em>: <c>DialogHost</c> assumed every <c>danger: true</c>
|
||||||
|
/// confirm was a delete, and no caller could say otherwise. These tests run the
|
||||||
|
/// real <see cref="DialogService"/> behind a real <c>DialogHost</c> — not a
|
||||||
|
/// substitute — so they cover both halves of the fix: the call site passing
|
||||||
|
/// <c>confirmLabel</c>, and the host preferring it over its own default. A
|
||||||
|
/// substitute-based test would pin the argument but not what the user reads.
|
||||||
|
///
|
||||||
|
/// bUnit cannot run real JS interop, so the host's <c>sbDialog.*</c> focus-trap
|
||||||
|
/// calls are exercised in <see cref="JSRuntimeMode.Loose"/> mode as no-ops.
|
||||||
|
/// </summary>
|
||||||
|
public class FailoverConfirmLabelTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly IManualFailoverService _failover = Substitute.For<IManualFailoverService>();
|
||||||
|
private readonly DialogService _dialog = new();
|
||||||
|
|
||||||
|
public FailoverConfirmLabelTests()
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtTokenService.UsernameClaimType, "tester"),
|
||||||
|
new Claim(JwtTokenService.RoleClaimType, "Administrator")
|
||||||
|
};
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||||
|
// BunitContext pre-registers a placeholder IAuthorizationService that throws when
|
||||||
|
// AuthorizeView evaluates a policy; force the real one so the admin gate is real.
|
||||||
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||||
|
Services.AddSingleton(_failover);
|
||||||
|
// The concrete service registered as the interface, so the control and the
|
||||||
|
// host share one instance (DialogHost injects IDialogService and casts).
|
||||||
|
Services.AddSingleton<IDialogService>(_dialog);
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the failover control and a live <c>DialogHost</c> side by side under
|
||||||
|
/// the cascading auth state <c>AuthorizeView</c> needs, mirroring how
|
||||||
|
/// <c>MainLayout</c> hosts the single shared dialog.
|
||||||
|
/// </summary>
|
||||||
|
private IRenderedComponent<CascadingAuthenticationState> RenderControlWithHost(string? siteId)
|
||||||
|
{
|
||||||
|
return Render<CascadingAuthenticationState>(parameters => parameters
|
||||||
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<CentralFailoverControl>(0);
|
||||||
|
builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), 2);
|
||||||
|
if (siteId is not null)
|
||||||
|
{
|
||||||
|
builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.CloseComponent();
|
||||||
|
builder.OpenComponent<DialogHost>(3);
|
||||||
|
builder.CloseComponent();
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the failover dialog and returns the footer buttons.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The click is deliberately NOT awaited. With a real <see cref="DialogService"/>
|
||||||
|
/// the control's handler parks on <c>await ConfirmAsync(...)</c> until someone
|
||||||
|
/// answers the dialog, so awaiting the click would deadlock the test. The dialog
|
||||||
|
/// state is published synchronously before <c>ConfirmAsync</c> returns its task, so
|
||||||
|
/// the modal is rendered by the time the click dispatch returns.
|
||||||
|
/// </remarks>
|
||||||
|
private IReadOnlyList<AngleSharp.Dom.IElement> OpenFailoverDialog(string? siteId)
|
||||||
|
{
|
||||||
|
var host = RenderControlWithHost(siteId);
|
||||||
|
var trigger = host.FindAll("button")
|
||||||
|
.First(b => b.TextContent.Contains("failover", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
_ = trigger.ClickAsync(new());
|
||||||
|
host.WaitForState(() => host.FindAll("div.modal-footer button").Count > 0);
|
||||||
|
|
||||||
|
return host.FindAll("div.modal-footer button").ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)] // central pair
|
||||||
|
[InlineData("SiteA")] // site pair
|
||||||
|
public void Failover_confirm_button_says_fail_over_not_delete(string? siteId)
|
||||||
|
{
|
||||||
|
var footerButtons = OpenFailoverDialog(siteId).Select(b => b.TextContent.Trim()).ToList();
|
||||||
|
|
||||||
|
Assert.Contains("Fail over", footerButtons);
|
||||||
|
// The whole point of the row: the destructive verb must not be "Delete".
|
||||||
|
Assert.DoesNotContain("Delete", footerButtons);
|
||||||
|
// Cancel is still the way out — the fix must not have eaten the footer.
|
||||||
|
Assert.Contains("Cancel", footerButtons);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Failover_confirm_button_keeps_destructive_styling()
|
||||||
|
{
|
||||||
|
// The label and the styling are separate concerns now (danger = red,
|
||||||
|
// confirmLabel = the verb). Relabelling must not have downgraded the
|
||||||
|
// button to a benign primary one.
|
||||||
|
var confirm = OpenFailoverDialog(siteId: null).First(b => b.TextContent.Trim() == "Fail over");
|
||||||
|
|
||||||
|
Assert.Contains("btn-danger", confirm.ClassList);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unlabelled_danger_confirm_still_falls_back_to_delete()
|
||||||
|
{
|
||||||
|
// The fallback is retained deliberately: ~20 genuine delete call sites rely
|
||||||
|
// on it (and the Playwright suites click the literal "Delete"). This pins that
|
||||||
|
// the row-28 fix is additive and did not change the default.
|
||||||
|
var cut = Render<DialogHost>();
|
||||||
|
|
||||||
|
await cut.InvokeAsync(() => { _ = _dialog.ConfirmAsync("Delete thing", "Sure?", danger: true); });
|
||||||
|
cut.Render();
|
||||||
|
|
||||||
|
var footerButtons = cut.FindAll("div.modal-footer button").Select(b => b.TextContent.Trim()).ToList();
|
||||||
|
Assert.Contains("Delete", footerButtons);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-10
@@ -99,7 +99,7 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
public async Task Confirming_triggers_the_failover_exactly_once()
|
public async Task Confirming_triggers_the_failover_exactly_once()
|
||||||
{
|
{
|
||||||
Arrange("Administrator");
|
Arrange("Administrator");
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
_failover.FailOverCentralAsync(Arg.Any<string>())
|
_failover.FailOverCentralAsync(Arg.Any<string>())
|
||||||
.Returns(Task.FromResult<string?>("akka.tcp://scadabridge@central-a:8081"));
|
.Returns(Task.FromResult<string?>("akka.tcp://scadabridge@central-a:8081"));
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
public async Task Cancelling_the_confirmation_does_not_trigger_a_failover()
|
public async Task Cancelling_the_confirmation_does_not_trigger_a_failover()
|
||||||
{
|
{
|
||||||
Arrange("Administrator");
|
Arrange("Administrator");
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(false);
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(false);
|
||||||
|
|
||||||
var cut = Render(onlineNodes: 2);
|
var cut = Render(onlineNodes: 2);
|
||||||
await cut.Find("button").ClickAsync(new());
|
await cut.Find("button").ClickAsync(new());
|
||||||
@@ -128,7 +128,7 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
// the admin's own circuit drops. If the dialog does not say so, a working failover
|
// the admin's own circuit drops. If the dialog does not say so, a working failover
|
||||||
// looks like a crash they caused.
|
// looks like a crash they caused.
|
||||||
Arrange("Administrator");
|
Arrange("Administrator");
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
|
|
||||||
var cut = Render(onlineNodes: 2);
|
var cut = Render(onlineNodes: 2);
|
||||||
await cut.Find("button").ClickAsync(new());
|
await cut.Find("button").ClickAsync(new());
|
||||||
@@ -136,7 +136,8 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
await _dialog.Received(1).ConfirmAsync(
|
await _dialog.Received(1).ConfirmAsync(
|
||||||
Arg.Any<string>(),
|
Arg.Any<string>(),
|
||||||
Arg.Is<string>(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
Arg.Is<string>(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
||||||
Arg.Any<bool>());
|
Arg.Any<bool>(),
|
||||||
|
Arg.Any<string?>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -145,7 +146,7 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
// The service returns null when the peer guard refuses. The UI must not report a
|
// The service returns null when the peer guard refuses. The UI must not report a
|
||||||
// failover that never happened.
|
// failover that never happened.
|
||||||
Arrange("Administrator");
|
Arrange("Administrator");
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
_failover.FailOverCentralAsync(Arg.Any<string>()).Returns(Task.FromResult<string?>(null));
|
_failover.FailOverCentralAsync(Arg.Any<string>()).Returns(Task.FromResult<string?>(null));
|
||||||
|
|
||||||
var cut = Render(onlineNodes: 2);
|
var cut = Render(onlineNodes: 2);
|
||||||
@@ -177,7 +178,7 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
public async Task Site_card_confirming_triggers_the_site_failover_path()
|
public async Task Site_card_confirming_triggers_the_site_failover_path()
|
||||||
{
|
{
|
||||||
Arrange("Administrator");
|
Arrange("Administrator");
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "akka.tcp://scadabridge@site-a-a:8082", null)));
|
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "akka.tcp://scadabridge@site-a-a:8082", null)));
|
||||||
|
|
||||||
@@ -194,7 +195,7 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
// Central failover drops the admin's circuit; a SITE failover does not, and saying
|
// Central failover drops the admin's circuit; a SITE failover does not, and saying
|
||||||
// otherwise would train operators to distrust the warning that does matter.
|
// otherwise would train operators to distrust the warning that does matter.
|
||||||
Arrange("Administrator");
|
Arrange("Administrator");
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null)));
|
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null)));
|
||||||
|
|
||||||
@@ -204,14 +205,15 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
await _dialog.Received(1).ConfirmAsync(
|
await _dialog.Received(1).ConfirmAsync(
|
||||||
Arg.Any<string>(),
|
Arg.Any<string>(),
|
||||||
Arg.Is<string>(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
Arg.Is<string>(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
||||||
Arg.Any<bool>());
|
Arg.Any<bool>(),
|
||||||
|
Arg.Any<string?>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Site_card_surfaces_a_refusal_from_the_site()
|
public async Task Site_card_surfaces_a_refusal_from_the_site()
|
||||||
{
|
{
|
||||||
Arrange("Administrator");
|
Arrange("Administrator");
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
||||||
false, null, "No standby available — failing over a lone node would be an outage.")));
|
false, null, "No standby available — failing over a lone node would be an outage.")));
|
||||||
@@ -228,7 +230,7 @@ public class HealthFailoverButtonTests : BunitContext
|
|||||||
// A timeout is not a "no" from the site — the operator must be able to tell the
|
// A timeout is not a "no" from the site — the operator must be able to tell the
|
||||||
// difference, because the failover may or may not have taken effect.
|
// difference, because the failover may or may not have taken effect.
|
||||||
Arrange("Administrator");
|
Arrange("Administrator");
|
||||||
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>()).Returns(true);
|
_dialog.ConfirmAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
|
||||||
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
_failover.FailOverSiteAsync(Arg.Any<string>(), Arg.Any<string>())
|
||||||
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
||||||
false, null, "Site did not respond: Ask timed out")));
|
false, null, "Site did not respond: Ask timed out")));
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ public class NotificationListsPageTests : BunitContext
|
|||||||
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
||||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||||
{
|
{
|
||||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
|
|||||||
+1
-1
@@ -336,7 +336,7 @@ public class NotificationReportDetailModalTests : BunitContext
|
|||||||
|
|
||||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||||
{
|
{
|
||||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ public class NotificationReportPageTests : BunitContext
|
|||||||
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
||||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||||
{
|
{
|
||||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ public sealed class QueryStringDrillInTests
|
|||||||
|
|
||||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||||
{
|
{
|
||||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
|
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
|
||||||
=> Task.FromResult<string?>(null);
|
=> Task.FromResult<string?>(null);
|
||||||
|
|||||||
@@ -679,7 +679,7 @@ public class SiteCallsReportPageTests : BunitContext
|
|||||||
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
|
||||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||||
{
|
{
|
||||||
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
|
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<string?> PromptAsync(
|
public Task<string?> PromptAsync(
|
||||||
|
|||||||
Reference in New Issue
Block a user