merge: small fixes (register rows 28, 35, 57) — confirm dialogs name their own action (9 mislabeled 'Delete' buttons fixed incl. secured-write Approve/Reject), DeploymentRecords composite (DeployedAt DESC, Id DESC) index, audit-config query CLI table projection
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
-- Register row 35: the deployment-list sort key gets its own index.
|
||||
--
|
||||
-- Replaces the single-column [IX_DeploymentRecords_DeployedAt] with the composite
|
||||
-- [IX_DeploymentRecords_DeployedAt_Id] ([DeployedAt] DESC, [Id] DESC), which is exactly
|
||||
-- the order DeploymentManagerRepository.QueryDeploymentListPageAsync (and
|
||||
-- QueryDeploymentSummariesAsync) asks for. With both key columns descending, a forward
|
||||
-- scan delivers the requested order, so a paged Skip/Take reads a Top-N off the index
|
||||
-- instead of sorting the whole filtered set on every page request.
|
||||
--
|
||||
-- The DROP is safe rather than merely tolerable. [DeployedAt] leads both indexes, so any
|
||||
-- predicate the old one served the composite serves too (direction does not affect
|
||||
-- seekability -- SQL Server can scan either way), and the leaf rows are the same width:
|
||||
-- [Id] is the clustered primary key and was therefore already in the old nonclustered
|
||||
-- index's leaf as the row locator. All that changes is that [Id] becomes a declared,
|
||||
-- ordered key column. Keeping both would maintain a second index on every deployment
|
||||
-- insert and status transition for no read benefit.
|
||||
--
|
||||
-- Note [SystemArtifactDeploymentRecords] is a DIFFERENT table with its own
|
||||
-- [DeployedAt] index; it is deliberately untouched.
|
||||
--
|
||||
-- DeploymentRecords is small enough that a plain (offline) index rebuild is fine. On an
|
||||
-- Enterprise edition instance where the deployment history is large and the Central UI
|
||||
-- must stay responsive, add WITH (ONLINE = ON) to the CREATE INDEX below.
|
||||
--
|
||||
-- Run with QUOTED_IDENTIFIER ON. Several tables in this database carry filtered indexes
|
||||
-- and persisted computed columns, and SQL Server refuses DDL/DML against them with error
|
||||
-- 1934 when the session has QUOTED_IDENTIFIER OFF -- which is exactly what `sqlcmd` gives
|
||||
-- you by default (pass -I, or keep this header). The SETs sit in their own batch so they
|
||||
-- are in force when the statements after the GO parse.
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260815165452_AddDeploymentRecordListSortIndex'
|
||||
)
|
||||
BEGIN
|
||||
EXEC(N'DROP INDEX [IX_DeploymentRecords_DeployedAt] ON [DeploymentRecords]');
|
||||
|
||||
EXEC(N'CREATE INDEX [IX_DeploymentRecords_DeployedAt_Id]
|
||||
ON [DeploymentRecords] ([DeployedAt] DESC, [Id] DESC)');
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM [__EFMigrationsHistory]
|
||||
WHERE [MigrationId] = N'20260815165452_AddDeploymentRecordListSortIndex'
|
||||
)
|
||||
BEGIN
|
||||
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||
VALUES (N'20260815165452_AddDeploymentRecordListSortIndex', N'10.0.7');
|
||||
END;
|
||||
|
||||
COMMIT;
|
||||
GO
|
||||
@@ -238,7 +238,7 @@ record of admin edits). `audit-log` is a deprecated alias that still works but e
|
||||
warning; migrate scripts to `audit-config`.
|
||||
|
||||
```
|
||||
scadabridge audit-config query [--user <username>] [--entity-type <type>] [--action <action>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>]
|
||||
scadabridge audit-config query [--user <username>] [--entity-type <type>] [--action <action>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>] [--detail]
|
||||
```
|
||||
|
||||
The response is an `AuditLogQueryResult` — `{ "entries": [ … ], "totalCount": <n> }`, where
|
||||
@@ -248,6 +248,16 @@ because the handler must not return the repository's `(Entries, TotalCount)` Val
|
||||
`{}` and every query looks empty regardless of how many rows matched. `ManagementActor.SerializeResult`
|
||||
now rejects tuple results outright so the failure mode cannot recur silently.
|
||||
|
||||
JSON is the primary format here and is never altered by the table path. `--format table`
|
||||
applies a compact projection (`AuditConfigTableProjection`) that unwraps the envelope into
|
||||
one row per entry — `id`, `timestamp`, `user`, `action`, `entityType`, `entityName`,
|
||||
`entityId` — and drops `afterStateJson`, the full post-change entity snapshot, since a
|
||||
single one fills the terminal. Without a projector the renderer took its object branch and
|
||||
printed the whole `entries` array as raw JSON inside one `Property`/`Value` cell (register
|
||||
row 57). `totalCount` survives the unwrap as a trailing `note` row, emitted only when the
|
||||
page is a strict subset of the matches. `--detail` skips the projection and renders the
|
||||
raw payload, mirroring `template list`/`template get`.
|
||||
|
||||
The new centralized Audit Log component (#23) is exposed via the `scadabridge audit` group below.
|
||||
|
||||
### Centralized Audit Commands
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- **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
|
||||
|
||||
@@ -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 **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.
|
||||
- **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.
|
||||
|
||||
### Site Event Log Viewer (Deployment Role)
|
||||
|
||||
@@ -120,7 +120,7 @@ The configuration database stores all central system data, organized by domain a
|
||||
- **Site Scoping Rules**: Per-mapping site scope restrictions for Deployment role.
|
||||
|
||||
### Deployment
|
||||
- **Deployment Records**: Deployment history per instance (timestamp, user, status, deployed configuration snapshot).
|
||||
- **Deployment Records**: Deployment history per instance (timestamp, user, status, deployed configuration snapshot). Indexes: unique on `DeploymentId` (deployment identity / idempotency), `InstanceId` (FK-backing, and the per-instance history read), and `IX_DeploymentRecords_DeployedAt_Id (DeployedAt DESC, Id DESC)` — the deployment list's exact sort key, so a paged read takes a Top-N off the index instead of sorting the whole filtered set per page. `Id` is in the key as the deterministic tie-break: a bulk site deploy stamps many rows in the same `DeployedAt` instant, and without it a row can repeat or vanish across page boundaries. This index **replaced** an earlier single-column one on `DeployedAt` (migration `AddDeploymentRecordListSortIndex`, deferred-work register row 35) rather than joining it — `DeployedAt` leads both, and `Id` is the clustered PK so it was already in the old index's leaf as the row locator, making the old index strictly redundant at identical leaf width.
|
||||
- **System-Wide Artifact Deployment Records**: Deployment history for shared artifacts (timestamp, user, artifact type, status).
|
||||
|
||||
### Transport
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Compact table projection for <c>audit-config query</c> (deferred-work register row 57).
|
||||
///
|
||||
/// <para>
|
||||
/// The handler returns an <c>AuditLogQueryResult</c> — <c>{ "entries": [ … ],
|
||||
/// "totalCount": N }</c>. Handed to the generic renderer unprojected, that object takes
|
||||
/// the Property/Value branch and the whole <c>entries</c> array lands in a single cell as
|
||||
/// raw JSON, so <c>--format table</c> was unreadable. This unwraps the envelope to the
|
||||
/// row array the renderer expects and drops <c>afterStateJson</c>, the per-entry snapshot
|
||||
/// of the entire changed entity — the same "one fat field makes the table useless" problem
|
||||
/// <see cref="TemplateTableProjection"/> solves by dropping child collections.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>totalCount</c> would be lost by a plain unwrap, so when the page is a strict subset
|
||||
/// of the matches a single trailing row carries a <c>note</c> cell saying how many of how
|
||||
/// many are shown. Every real entry leaves that cell empty, and when the page IS the whole
|
||||
/// result set the column is not emitted at all — so the common small-query table stays
|
||||
/// exactly as narrow as its data.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// JSON output is never touched (this runs only on the table path), and the command's
|
||||
/// <c>--detail</c> flag skips the projection to render the raw payload as-is — that is
|
||||
/// the way to see <c>afterStateJson</c> in table form.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class AuditConfigTableProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects an <c>AuditLogQueryResult</c> envelope (or a bare entry array) to the
|
||||
/// compact row array the table renderer consumes. Returns the input unchanged when it
|
||||
/// is not JSON or not one of those shapes, so the renderer's own fallbacks still apply.
|
||||
/// </summary>
|
||||
/// <param name="json">The raw success JSON body from the management API.</param>
|
||||
/// <returns>Compact JSON suitable for table rendering.</returns>
|
||||
internal static string ProjectEntries(string json)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not JSON (e.g. a proxy error page) — let the renderer print it verbatim.
|
||||
return json;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
|
||||
// A bare array is accepted so the projection still works if the payload is
|
||||
// ever unwrapped upstream, and so the deprecated `audit-log` alias against an
|
||||
// older server does not render worse than it does today.
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return BuildRows(root, totalCount: null).ToJsonString();
|
||||
}
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& TryGetPropertyCI(root, "entries", out var entries)
|
||||
&& entries.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
int? total = TryGetPropertyCI(root, "totalCount", out var tc)
|
||||
&& tc.ValueKind == JsonValueKind.Number
|
||||
&& tc.TryGetInt32(out var n)
|
||||
? n
|
||||
: null;
|
||||
return BuildRows(entries, total).ToJsonString();
|
||||
}
|
||||
|
||||
// Some other object (e.g. an error envelope) — leave it to the renderer.
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonArray BuildRows(JsonElement entries, int? totalCount)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
var shown = 0;
|
||||
foreach (var item in entries.EnumerateArray())
|
||||
{
|
||||
arr.Add(ProjectElement(item));
|
||||
shown++;
|
||||
}
|
||||
|
||||
// Only when the page is a strict subset — otherwise the count is just the row
|
||||
// count the operator can already see, and the extra column would be noise.
|
||||
if (totalCount is int total && total > shown)
|
||||
{
|
||||
arr.Add(new JsonObject
|
||||
{
|
||||
["note"] = JsonValue.Create(
|
||||
$"showing {shown} of {total} — use --page / --page-size for the rest"),
|
||||
});
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
/// <summary>Projects a single audit entry to its compact row node.</summary>
|
||||
private static JsonNode ProjectElement(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
return JsonValue.Create(element.ToString())!;
|
||||
|
||||
// JsonObject preserves insertion order, which fixes the table's column order.
|
||||
// afterStateJson (the full post-change entity snapshot) and bundleImportId are
|
||||
// deliberately omitted — `--detail`, or --format json, for those.
|
||||
return new JsonObject
|
||||
{
|
||||
["id"] = Int(element, "id"),
|
||||
["timestamp"] = Str(element, "timestamp"),
|
||||
["user"] = Str(element, "user"),
|
||||
["action"] = Str(element, "action"),
|
||||
["entityType"] = Str(element, "entityType"),
|
||||
["entityName"] = Str(element, "entityName"),
|
||||
["entityId"] = Str(element, "entityId"),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JsonNode? Str(JsonElement obj, string name)
|
||||
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? JsonValue.Create(v.GetString())
|
||||
: null;
|
||||
|
||||
private static JsonNode? Int(JsonElement obj, string name)
|
||||
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n)
|
||||
? JsonValue.Create(n)
|
||||
: null;
|
||||
}
|
||||
@@ -75,6 +75,13 @@ public static class AuditLogCommands
|
||||
pageOption.DefaultValueFactory = _ => 1;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
||||
var detailOption = new Option<bool>("--detail")
|
||||
{
|
||||
Description = "Render the raw query payload in table output instead of the compact column projection. "
|
||||
+ "This is how to see 'afterStateJson' (the full post-change entity snapshot) in table form — it is "
|
||||
+ "omitted from the compact columns because a single entry's snapshot fills the terminal. "
|
||||
+ "No effect on JSON output."
|
||||
};
|
||||
|
||||
var cmd = new Command("query") { Description = "Query audit log entries" };
|
||||
cmd.Add(userOption);
|
||||
@@ -84,6 +91,7 @@ public static class AuditLogCommands
|
||||
cmd.Add(toOption);
|
||||
cmd.Add(pageOption);
|
||||
cmd.Add(pageSizeOption);
|
||||
cmd.Add(detailOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var user = result.GetValue(userOption);
|
||||
@@ -93,9 +101,11 @@ public static class AuditLogCommands
|
||||
var to = result.GetValue(toOption);
|
||||
var page = result.GetValue(pageOption);
|
||||
var pageSize = result.GetValue(pageSizeOption);
|
||||
var detail = result.GetValue(detailOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize));
|
||||
new QueryAuditLogCommand(user, entityType, action, from, to, page, pageSize),
|
||||
tableProjector: detail ? null : AuditConfigTableProjection.ProjectEntries);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -1801,6 +1801,7 @@ scadabridge --url <url> audit-config query [options]
|
||||
| `--to` | no | — | End timestamp in ISO 8601 format |
|
||||
| `--page` | no | `1` | Page number |
|
||||
| `--page-size` | no | `50` | Results per page |
|
||||
| `--detail` | no | `false` | Table output only: render the raw payload instead of the compact column projection (see below). No effect on JSON output. |
|
||||
|
||||
Output shape:
|
||||
|
||||
@@ -1822,7 +1823,25 @@ Output shape:
|
||||
```
|
||||
|
||||
`totalCount` is the unpaged number of rows matching the filters, so a client can page
|
||||
without a second call.
|
||||
without a second call. JSON is the primary format for this command and is what scripts
|
||||
should consume — it is never altered by the table projection below.
|
||||
|
||||
**Table output.** `--format table` renders a compact projection with one row per entry:
|
||||
|
||||
```
|
||||
id timestamp user action entityType entityName entityId
|
||||
412 2026-08-15T12:00:00+00:00 multi-role Created Template PumpTemplate 7
|
||||
413 2026-08-15T12:05:00+00:00 designer Deleted ExternalSystem MES 3
|
||||
```
|
||||
|
||||
`afterStateJson` (the full post-change entity snapshot) and `bundleImportId` are omitted
|
||||
— one snapshot per row fills a terminal. Pass `--detail` to render the raw payload
|
||||
instead, or use `--format json`. When the page is a strict subset of the matches, a final
|
||||
row carries a `note` cell (`showing 50 of 137 — use --page / --page-size for the rest`);
|
||||
when the page is the whole result set that column is not emitted at all.
|
||||
|
||||
Before this projection existed the whole `entries` array was printed as raw JSON inside a
|
||||
single `Property`/`Value` cell (deferred-work register row 57).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -70,14 +70,16 @@
|
||||
+ "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 "
|
||||
+ "unavailable while the handover completes.",
|
||||
danger: true)
|
||||
danger: true,
|
||||
confirmLabel: "Fail over")
|
||||
: await Dialog.ConfirmAsync(
|
||||
"Trigger central failover?",
|
||||
"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 "
|
||||
+ "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.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Fail over");
|
||||
|
||||
if (!confirmed)
|
||||
{
|
||||
|
||||
@@ -868,7 +868,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Disable Instance",
|
||||
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Disable");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
+4
-2
@@ -668,7 +668,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard parked messages",
|
||||
$"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed) return;
|
||||
|
||||
_bulkInProgress = true;
|
||||
@@ -720,7 +721,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard parked message",
|
||||
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed) return false;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
+2
-1
@@ -566,7 +566,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard notification",
|
||||
$"Permanently discard notification {ShortId(n.NotificationId)} (\"{n.Subject}\")? This cannot be undone.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
@@ -424,7 +424,8 @@
|
||||
$"Value: {row.ValueJson} ({row.ValueType})\n" +
|
||||
$"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" +
|
||||
$"Operator: {row.OperatorUser}",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Approve");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
@@ -460,7 +461,8 @@
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Reject secured write",
|
||||
$"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Reject");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
+2
-1
@@ -343,7 +343,8 @@ public partial class SiteCallsReport
|
||||
"Discard cached call",
|
||||
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
|
||||
$"to site {SiteName(c.SourceSite)}? This cannot be undone.",
|
||||
danger: true);
|
||||
danger: true,
|
||||
confirmLabel: "Discard");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
|
||||
@@ -185,9 +185,17 @@
|
||||
svc.Resolve(resolveValue);
|
||||
}
|
||||
|
||||
private static string ConfirmLabel(DialogState state) => state.Kind switch
|
||||
{
|
||||
DialogKind.Prompt => "Save",
|
||||
_ => state.Danger ? "Delete" : "Confirm",
|
||||
};
|
||||
// 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
|
||||
// be a delete, which is exactly the assumption that put "Delete" on the Trigger
|
||||
// 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;
|
||||
|
||||
/// <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();
|
||||
var tcs = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_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();
|
||||
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>
|
||||
/// <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>
|
||||
/// <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(
|
||||
string Title,
|
||||
DialogKind Kind,
|
||||
@@ -154,7 +164,8 @@ public record DialogState(
|
||||
string PromptInitial,
|
||||
string? Placeholder,
|
||||
RenderFragment? Content = null,
|
||||
string? Size = null);
|
||||
string? Size = null,
|
||||
string? ConfirmLabel = null);
|
||||
|
||||
public enum DialogKind
|
||||
{
|
||||
|
||||
@@ -19,10 +19,17 @@ public interface IDialogService
|
||||
/// <param name="title">Modal title text.</param>
|
||||
/// <param name="message">Body text shown to the user.</param>
|
||||
/// <param name="danger">When <c>true</c>, the confirm button renders in
|
||||
/// <c>btn-danger</c> styling with the label "Delete"; otherwise a primary
|
||||
/// "Confirm" button is shown.</param>
|
||||
/// <c>btn-danger</c> styling; otherwise a primary button is shown. This
|
||||
/// 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>
|
||||
Task<bool> ConfirmAsync(string title, string message, bool danger = false);
|
||||
Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null);
|
||||
|
||||
/// <summary>
|
||||
/// Shows a single-line text prompt and resolves to the entered value, or
|
||||
|
||||
+20
-1
@@ -39,7 +39,26 @@ public class DeploymentRecordConfiguration : IEntityTypeConfiguration<Deployment
|
||||
|
||||
builder.HasIndex(d => d.DeploymentId).IsUnique();
|
||||
builder.HasIndex(d => d.InstanceId);
|
||||
builder.HasIndex(d => d.DeployedAt);
|
||||
|
||||
// Deployment-list sort key. Every list read orders DeployedAt DESC, Id DESC
|
||||
// (DeploymentManagerRepository.QueryDeploymentListPageAsync and
|
||||
// QueryDeploymentSummariesAsync) — Id is the deterministic tie-break, because
|
||||
// a bulk site deploy stamps many rows in the same DeployedAt instant and a
|
||||
// non-deterministic order would let a row repeat or vanish across pages.
|
||||
// Declaring both key columns descending lets a forward scan feed the paged
|
||||
// Skip/Take directly instead of the server sorting the whole filtered set per
|
||||
// page (deferred-work register row 35).
|
||||
//
|
||||
// This REPLACES the old single-column HasIndex(d => d.DeployedAt): that index
|
||||
// is strictly redundant here, not merely overlapping. DeployedAt leads both, so
|
||||
// any predicate the old one served the composite serves too (direction does not
|
||||
// affect seekability — SQL Server scans either way), and the leaf is the same
|
||||
// width, since Id is the clustered PK and was therefore already sitting in the
|
||||
// old index's leaf as the row locator. Keeping it would buy nothing and cost a
|
||||
// second index maintained on every insert and status transition.
|
||||
builder.HasIndex(d => new { d.DeployedAt, d.Id })
|
||||
.IsDescending(true, true)
|
||||
.HasDatabaseName("IX_DeploymentRecords_DeployedAt_Id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2123
File diff suppressed because it is too large
Load Diff
+60
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Replaces the single-column <c>IX_DeploymentRecords_DeployedAt</c> with the
|
||||
/// composite <c>IX_DeploymentRecords_DeployedAt_Id (DeployedAt DESC, Id DESC)</c>,
|
||||
/// which is the deployment list's exact sort key (deferred-work register row 35).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The list read (<c>DeploymentManagerRepository.QueryDeploymentListPageAsync</c>)
|
||||
/// joins <c>Instance</c> to make <c>SiteId</c> filterable, then orders
|
||||
/// <c>DeployedAt DESC, Id DESC</c> and pages with Skip/Take. With both key columns
|
||||
/// declared descending, a forward scan of this index already delivers rows in the
|
||||
/// requested order, so the plan can feed the Top-N page directly rather than sorting
|
||||
/// the whole filtered set on every page request.
|
||||
///
|
||||
/// The drop is safe rather than merely tolerable: <c>DeployedAt</c> leads both
|
||||
/// indexes, so every predicate the old one served the composite serves as well
|
||||
/// (index direction does not affect seekability — SQL Server scans either
|
||||
/// direction), and the leaf rows are the same width, because <c>Id</c> is the
|
||||
/// clustered primary key and was therefore already present in the old nonclustered
|
||||
/// index's leaf as the row locator. What changes is only that <c>Id</c> is now a
|
||||
/// declared, ordered key column instead of an implicit locator. Keeping both would
|
||||
/// add a second index to maintain on every insert and status transition for no read
|
||||
/// benefit. <c>SystemArtifactDeploymentRecords</c> has its own separate
|
||||
/// <c>DeployedAt</c> index and is deliberately untouched.
|
||||
/// </remarks>
|
||||
public partial class AddDeploymentRecordListSortIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt",
|
||||
table: "DeploymentRecords");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt_Id",
|
||||
table: "DeploymentRecords",
|
||||
columns: new[] { "DeployedAt", "Id" },
|
||||
descending: new bool[0]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt_Id",
|
||||
table: "DeploymentRecords");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt",
|
||||
table: "DeploymentRecords",
|
||||
column: "DeployedAt");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -267,13 +267,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DeployedAt");
|
||||
|
||||
b.HasIndex("DeploymentId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("InstanceId");
|
||||
|
||||
b.HasIndex("DeployedAt", "Id")
|
||||
.IsDescending()
|
||||
.HasDatabaseName("IX_DeploymentRecords_DeployedAt_Id");
|
||||
|
||||
b.ToTable("DeploymentRecords");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the compact <c>audit-config query</c> table projection (deferred-work
|
||||
/// register row 57). The command passed no <c>tableProjector</c>, so the generic
|
||||
/// renderer saw an <see cref="AuditLogQueryResult"/> OBJECT, took its Property/Value
|
||||
/// branch, and printed the entire <c>entries</c> array as raw JSON inside one cell.
|
||||
/// The projection unwraps the envelope to a row array, drops the per-entry
|
||||
/// <c>afterStateJson</c> snapshot, and keeps <c>totalCount</c> visible as a trailing note.
|
||||
/// </summary>
|
||||
public class AuditConfigTableProjectionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The real server shape, serialised from the REAL <see cref="AuditLogQueryResult"/>
|
||||
/// / <see cref="AuditLogEntry"/> types rather than hand-written JSON, so renaming one
|
||||
/// of their properties fails this test instead of silently blanking a column.
|
||||
/// </summary>
|
||||
private static string RealPayload(int totalCount) =>
|
||||
JsonSerializer.Serialize(
|
||||
new AuditLogQueryResult(
|
||||
new[]
|
||||
{
|
||||
new AuditLogEntry("multi-role", "Created", "Template", "7", "PumpTemplate")
|
||||
{
|
||||
Id = 412,
|
||||
AfterStateJson = "{\"id\":7,\"name\":\"PumpTemplate\",\"attributes\":[]}",
|
||||
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
},
|
||||
new AuditLogEntry("designer", "Deleted", "ExternalSystem", "3", "MES")
|
||||
{
|
||||
Id = 413,
|
||||
AfterStateJson = null,
|
||||
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 5, 0, TimeSpan.Zero),
|
||||
},
|
||||
},
|
||||
totalCount),
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
|
||||
{
|
||||
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
|
||||
|
||||
using var doc = JsonDocument.Parse(compact);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// An ARRAY is the whole point — the object envelope is what made the renderer
|
||||
// print one Property/Value row with the entries blob in a single cell.
|
||||
Assert.Equal(JsonValueKind.Array, root.ValueKind);
|
||||
Assert.Equal(2, root.GetArrayLength());
|
||||
|
||||
var first = root[0];
|
||||
Assert.Equal(412, first.GetProperty("id").GetInt32());
|
||||
Assert.Equal("multi-role", first.GetProperty("user").GetString());
|
||||
Assert.Equal("Created", first.GetProperty("action").GetString());
|
||||
Assert.Equal("Template", first.GetProperty("entityType").GetString());
|
||||
Assert.Equal("PumpTemplate", first.GetProperty("entityName").GetString());
|
||||
Assert.Equal("7", first.GetProperty("entityId").GetString());
|
||||
Assert.Contains("2026-08-15", first.GetProperty("timestamp").GetString());
|
||||
|
||||
var second = root[1];
|
||||
Assert.Equal("Deleted", second.GetProperty("action").GetString());
|
||||
Assert.Equal("MES", second.GetProperty("entityName").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_DropsAfterStateJson()
|
||||
{
|
||||
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
|
||||
|
||||
using var doc = JsonDocument.Parse(compact);
|
||||
// The fat field must NOT survive — one entity snapshot per row fills the terminal,
|
||||
// which is the audit analogue of template's child arrays.
|
||||
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
|
||||
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
|
||||
{
|
||||
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 137));
|
||||
|
||||
using var doc = JsonDocument.Parse(compact);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Two entries plus the note row.
|
||||
Assert.Equal(3, root.GetArrayLength());
|
||||
var note = root[2].GetProperty("note").GetString();
|
||||
Assert.Contains("2 of 137", note);
|
||||
// The note must not masquerade as an entry.
|
||||
Assert.False(root[2].TryGetProperty("id", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
|
||||
{
|
||||
// When the page IS the result set the count adds nothing the operator cannot
|
||||
// already see, so the column must not appear and widen every small query.
|
||||
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
|
||||
|
||||
using var doc = JsonDocument.Parse(compact);
|
||||
Assert.Equal(2, doc.RootElement.GetArrayLength());
|
||||
Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new AuditLogQueryResult(Array.Empty<AuditLogEntry>(), 0),
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
var compact = AuditConfigTableProjection.ProjectEntries(json);
|
||||
|
||||
using var doc = JsonDocument.Parse(compact);
|
||||
// The renderer prints "(no results)" for an empty array; an empty OBJECT would
|
||||
// have printed an empty Property/Value table instead.
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_BareArray_IsStillProjected()
|
||||
{
|
||||
// Tolerated so the projection does not regress if the payload is ever unwrapped
|
||||
// upstream, or when the deprecated `audit-log` alias hits an older server.
|
||||
const string bare = """
|
||||
[ { "id": 1, "user": "u", "action": "Updated", "entityType": "Site",
|
||||
"entityName": "SiteA", "entityId": "2", "timestamp": "2026-08-15T12:00:00+00:00",
|
||||
"afterStateJson": "{\"big\":true}" } ]
|
||||
""";
|
||||
|
||||
var compact = AuditConfigTableProjection.ProjectEntries(bare);
|
||||
|
||||
using var doc = JsonDocument.Parse(compact);
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("SiteA", doc.RootElement[0].GetProperty("entityName").GetString());
|
||||
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
|
||||
{
|
||||
// e.g. an error envelope — leave it to the renderer's own fallbacks rather than
|
||||
// projecting it into an empty table that hides the message.
|
||||
const string other = """{ "error": "boom" }""";
|
||||
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_NonJson_ReturnedVerbatim()
|
||||
{
|
||||
const string notJson = "<html>proxy error</html>";
|
||||
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
|
||||
{
|
||||
// The reported symptom was the whole entries array in one cell. A page of rows
|
||||
// carrying real afterStateJson snapshots must collapse hard.
|
||||
var entries = Enumerable.Range(0, 50).Select(i =>
|
||||
new AuditLogEntry("designer", "Updated", "Template", i.ToString(), $"Template{i}")
|
||||
{
|
||||
Id = i,
|
||||
AfterStateJson = new string('x', 2000),
|
||||
Timestamp = DateTimeOffset.UnixEpoch.AddMinutes(i),
|
||||
}).ToArray();
|
||||
var json = JsonSerializer.Serialize(new AuditLogQueryResult(entries, entries.Length),
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
var compact = AuditConfigTableProjection.ProjectEntries(json);
|
||||
|
||||
Assert.True(compact.Length * 4 < json.Length,
|
||||
$"Expected compact ({compact.Length}) to be far smaller than raw ({json.Length}).");
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public class SecuredWritesTests : BunitContext
|
||||
Services.AddSingleton(_browse);
|
||||
|
||||
// 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));
|
||||
|
||||
// Empty list by default; individual tests seed the queue/history.
|
||||
@@ -303,7 +303,8 @@ public class SecuredWritesTests : BunitContext
|
||||
_dialog.Received(1).ConfirmAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<string>(m => Regex.IsMatch(m, "Submitted .* ago")),
|
||||
Arg.Any<bool>());
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[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>
|
||||
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);
|
||||
|
||||
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()
|
||||
{
|
||||
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?>("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()
|
||||
{
|
||||
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);
|
||||
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
|
||||
// looks like a crash they caused.
|
||||
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);
|
||||
await cut.Find("button").ClickAsync(new());
|
||||
@@ -136,7 +136,8 @@ public class HealthFailoverButtonTests : BunitContext
|
||||
await _dialog.Received(1).ConfirmAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<string>(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
||||
Arg.Any<bool>());
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -145,7 +146,7 @@ public class HealthFailoverButtonTests : BunitContext
|
||||
// The service returns null when the peer guard refuses. The UI must not report a
|
||||
// failover that never happened.
|
||||
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));
|
||||
|
||||
var cut = Render(onlineNodes: 2);
|
||||
@@ -177,7 +178,7 @@ public class HealthFailoverButtonTests : BunitContext
|
||||
public async Task Site_card_confirming_triggers_the_site_failover_path()
|
||||
{
|
||||
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>())
|
||||
.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
|
||||
// otherwise would train operators to distrust the warning that does matter.
|
||||
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>())
|
||||
.Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null)));
|
||||
|
||||
@@ -204,14 +205,15 @@ public class HealthFailoverButtonTests : BunitContext
|
||||
await _dialog.Received(1).ConfirmAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<string>(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)),
|
||||
Arg.Any<bool>());
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Site_card_surfaces_a_refusal_from_the_site()
|
||||
{
|
||||
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>())
|
||||
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
||||
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
|
||||
// difference, because the failover may or may not have taken effect.
|
||||
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>())
|
||||
.Returns(Task.FromResult(new SiteFailoverOutcome(
|
||||
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>
|
||||
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);
|
||||
|
||||
public Task<string?> PromptAsync(
|
||||
|
||||
+1
-1
@@ -336,7 +336,7 @@ public class NotificationReportDetailModalTests : BunitContext
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
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);
|
||||
|
||||
public Task<string?> PromptAsync(
|
||||
|
||||
@@ -211,7 +211,7 @@ public sealed class QueryStringDrillInTests
|
||||
|
||||
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);
|
||||
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = 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>
|
||||
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);
|
||||
|
||||
public Task<string?> PromptAsync(
|
||||
|
||||
Reference in New Issue
Block a user