From 0db253b55f88344716e554f9c1039666a606d320 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 13:22:15 -0400 Subject: [PATCH 1/3] fix(centralui): confirm dialogs name their own action instead of always saying "Delete" (register row 28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/requirements/Component-CentralUI.md | 2 + .../Health/CentralFailoverControl.razor | 6 +- .../Pages/Deployment/Topology.razor | 3 +- .../Pages/Monitoring/ParkedMessages.razor | 6 +- .../Notifications/NotificationReport.razor | 3 +- .../Pages/Operations/SecuredWrites.razor | 6 +- .../Pages/SiteCalls/SiteCallsReport.razor.cs | 3 +- .../Components/Shared/DialogHost.razor | 18 ++- .../Components/Shared/DialogService.cs | 17 ++- .../Components/Shared/IDialogService.cs | 13 +- .../Components/SecuredWritesTests.cs | 5 +- .../Design/SchemaLibraryPageTests.cs | 2 +- .../Monitoring/FailoverConfirmLabelTests.cs | 138 ++++++++++++++++++ .../Monitoring/HealthFailoverButtonTests.cs | 22 +-- .../Pages/NotificationListsPageTests.cs | 2 +- .../NotificationReportDetailModalTests.cs | 2 +- .../Pages/NotificationReportPageTests.cs | 2 +- .../Pages/QueryStringDrillInTests.cs | 2 +- .../Pages/SiteCallsReportPageTests.cs | 2 +- 19 files changed, 216 insertions(+), 38 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index fdafd323..8fe002eb 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -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) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor index 9bb751ff..33c16242 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/CentralFailoverControl.razor @@ -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) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor index 4fef9a50..22dcd817 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor @@ -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; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor index df38904a..d9164280 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor @@ -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; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor index 69303273..5af9ffb2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor @@ -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; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor index db1b5f11..cbe873e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor @@ -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; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs index c501baaa..72d34fdc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs @@ -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; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor index 51efbb36..22bac228 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor @@ -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", + }; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs index 134c06d3..3baa15e3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs @@ -35,12 +35,19 @@ public class DialogService : IDialogService private TaskCompletionSource? _tcs; /// - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) { EnsureNoActiveDialog(); var tcs = new TaskCompletionSource(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 .modal-body; null for confirm/prompt. /// Optional Bootstrap modal-dialog size modifier class applied /// to the modal-dialog element (e.g. modal-lg); null = default. +/// Explicit verb for the confirm button. null or +/// blank falls back to the kind/danger default ("Save" for a prompt, "Delete" for a +/// danger confirm, "Confirm" otherwise). 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 { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs index e174ed0f..0b90d48e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs @@ -19,10 +19,17 @@ public interface IDialogService /// Modal title text. /// Body text shown to the user. /// When true, the confirm button renders in - /// btn-danger styling with the label "Delete"; otherwise a primary - /// "Confirm" button is shown. + /// btn-danger styling; otherwise a primary button is shown. This + /// controls styling only — it does not name the action. + /// Verb rendered on the confirm button. When + /// null or blank the button falls back to "Delete" for a danger + /// dialog and "Confirm" otherwise. Any destructive action that is + /// not literally a delete must pass this — 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 + /// Component-CentralUI.md → Dialog Service. /// A task that resolves to true when the user confirms, or false when cancelled. - Task ConfirmAsync(string title, string message, bool danger = false); + Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null); /// /// Shows a single-line text prompt and resolves to the entered value, or diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs index a1495c5d..d57ba929 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs @@ -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(), Arg.Any(), Arg.Any()) + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .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(), Arg.Is(m => Regex.IsMatch(m, "Submitted .* ago")), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs index 48ea76bf..c9182cf0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/SchemaLibraryPageTests.cs @@ -210,7 +210,7 @@ public class SchemaLibraryPageTests : BunitContext /// A dialog service that auto-confirms, so the delete path runs end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs new file mode 100644 index 00000000..37bb4e99 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/FailoverConfirmLabelTests.cs @@ -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; + +/// +/// 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 "Delete": DialogHost assumed every danger: true +/// confirm was a delete, and no caller could say otherwise. These tests run the +/// real behind a real DialogHost — not a +/// substitute — so they cover both halves of the fix: the call site passing +/// confirmLabel, 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 sbDialog.* focus-trap +/// calls are exercised in mode as no-ops. +/// +public class FailoverConfirmLabelTests : BunitContext +{ + private readonly IManualFailoverService _failover = Substitute.For(); + 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(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(); + 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(_dialog); + JSInterop.Mode = JSRuntimeMode.Loose; + } + + /// + /// Renders the failover control and a live DialogHost side by side under + /// the cascading auth state AuthorizeView needs, mirroring how + /// MainLayout hosts the single shared dialog. + /// + private IRenderedComponent RenderControlWithHost(string? siteId) + { + return Render(parameters => parameters + .Add(p => p.ChildContent, (RenderFragment)(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(CentralFailoverControl.OnlineCentralNodeCount), 2); + if (siteId is not null) + { + builder.AddAttribute(2, nameof(CentralFailoverControl.SiteId), siteId); + } + + builder.CloseComponent(); + builder.OpenComponent(3); + builder.CloseComponent(); + }))); + } + + /// + /// Opens the failover dialog and returns the footer buttons. + /// + /// + /// The click is deliberately NOT awaited. With a real + /// the control's handler parks on await ConfirmAsync(...) until someone + /// answers the dialog, so awaiting the click would deadlock the test. The dialog + /// state is published synchronously before ConfirmAsync returns its task, so + /// the modal is rendered by the time the click dispatch returns. + /// + private IReadOnlyList 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(); + + 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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs index 138f5151..c3e3a752 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/HealthFailoverButtonTests.cs @@ -99,7 +99,7 @@ public class HealthFailoverButtonTests : BunitContext public async Task Confirming_triggers_the_failover_exactly_once() { Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverCentralAsync(Arg.Any()) .Returns(Task.FromResult("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(), Arg.Any(), Arg.Any()).Returns(false); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).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(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).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(), Arg.Is(m => m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [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(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverCentralAsync(Arg.Any()).Returns(Task.FromResult(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(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .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(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new SiteFailoverOutcome(true, "addr", null))); @@ -204,14 +205,15 @@ public class HealthFailoverButtonTests : BunitContext await _dialog.Received(1).ConfirmAsync( Arg.Any(), Arg.Is(m => !m.Contains("reconnect", StringComparison.OrdinalIgnoreCase)), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] public async Task Site_card_surfaces_a_refusal_from_the_site() { Arrange("Administrator"); - _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .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(), Arg.Any(), Arg.Any()).Returns(true); + _dialog.ConfirmAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); _failover.FailOverSiteAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new SiteFailoverOutcome( false, null, "Site did not respond: Ask timed out"))); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs index 04d3ec46..196f7818 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs @@ -191,7 +191,7 @@ public class NotificationListsPageTests : BunitContext /// A dialog service that auto-confirms, so action paths run end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs index 0c238a2a..d1daade5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs @@ -336,7 +336,7 @@ public class NotificationReportDetailModalTests : BunitContext private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs index c5fc15d5..7d9fda24 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs @@ -271,7 +271,7 @@ public class NotificationReportPageTests : BunitContext /// A dialog service that auto-confirms, so action paths run end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs index 5026caf6..72521239 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs @@ -211,7 +211,7 @@ public sealed class QueryStringDrillInTests private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync(string title, string label, string initialValue = "", string? placeholder = null) => Task.FromResult(null); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs index d32ebff3..e7629476 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -679,7 +679,7 @@ public class SiteCallsReportPageTests : BunitContext /// A dialog service that auto-confirms, so action paths run end-to-end. private sealed class AlwaysConfirmDialogService : IDialogService { - public Task ConfirmAsync(string title, string message, bool danger = false) + public Task ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null) => Task.FromResult(true); public Task PromptAsync( From be29bf471e4af2b07cffc3b8109346d5152264f5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 13:22:29 -0400 Subject: [PATCH 2/3] perf(configdb): index DeploymentRecords on the deployment-list sort key (register row 35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployment list orders `DeployedAt DESC, Id DESC` and joins `Instance` to make SiteId filterable, but the only DeployedAt index was single-column and ascending, so the server sorted the filtered set on every page request. Adds `IX_DeploymentRecords_DeployedAt_Id (DeployedAt DESC, Id DESC)` — the exact sort key, both columns descending, so a forward scan feeds the paged Skip/Take directly. 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. Three reads share the key (QueryDeploymentListPageAsync, QueryDeploymentSummariesAsync, and the single-row GetCurrentDeploymentStatusAsync). DROPS the old `IX_DeploymentRecords_DeployedAt` rather than keeping both. It is strictly redundant, not merely overlapping: DeployedAt leads both, so every predicate it served the composite serves too (direction does not affect seekability — SQL Server scans either way), and the leaf rows are the same width, because Id is the clustered PK and was therefore already in the old nonclustered index's leaf as the row locator. All that changes is Id becoming a declared, ordered key column. Keeping it would maintain a second index on every deployment insert and status transition for no read benefit. Honest caveat: because the clustering key is implicitly ordered within a nonclustered index, the optimizer may already have been able to serve this ordering by scanning the old index backward. The explicit composite makes it deterministic and self-documenting rather than dependent on that inference, and costs nothing to declare. Migration 20260815165452_AddDeploymentRecordListSortIndex (scaffolded with a real build, non-empty, snapshot updated) plus the hand-written production script under docs/plans/sql/ following the AddAuditLogAndNotificationCoveringIndexes precedent. SystemArtifactDeploymentRecords has its own separate DeployedAt index and is deliberately untouched. --- .../sql/AddDeploymentRecordListSortIndex.sql | 56 + .../Component-ConfigurationDatabase.md | 2 +- .../Configurations/DeploymentConfiguration.cs | 21 +- ...dDeploymentRecordListSortIndex.Designer.cs | 2123 +++++++++++++++++ ...165452_AddDeploymentRecordListSortIndex.cs | 60 + .../ScadaBridgeDbContextModelSnapshot.cs | 6 +- 6 files changed, 2264 insertions(+), 4 deletions(-) create mode 100644 docs/plans/sql/AddDeploymentRecordListSortIndex.sql create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815165452_AddDeploymentRecordListSortIndex.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815165452_AddDeploymentRecordListSortIndex.cs diff --git a/docs/plans/sql/AddDeploymentRecordListSortIndex.sql b/docs/plans/sql/AddDeploymentRecordListSortIndex.sql new file mode 100644 index 00000000..1f537ae9 --- /dev/null +++ b/docs/plans/sql/AddDeploymentRecordListSortIndex.sql @@ -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 diff --git a/docs/requirements/Component-ConfigurationDatabase.md b/docs/requirements/Component-ConfigurationDatabase.md index 491e70b2..699de7d1 100644 --- a/docs/requirements/Component-ConfigurationDatabase.md +++ b/docs/requirements/Component-ConfigurationDatabase.md @@ -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 diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/DeploymentConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/DeploymentConfiguration.cs index 6487892c..69b437ba 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/DeploymentConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/DeploymentConfiguration.cs @@ -39,7 +39,26 @@ public class DeploymentRecordConfiguration : IEntityTypeConfiguration 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"); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815165452_AddDeploymentRecordListSortIndex.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815165452_AddDeploymentRecordListSortIndex.Designer.cs new file mode 100644 index 00000000..1be9e1c0 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815165452_AddDeploymentRecordListSortIndex.Designer.cs @@ -0,0 +1,2123 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260815165452_AddDeploymentRecordListSortIndex")] + partial class AddDeploymentRecordListSortIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_SiteCalls_NonTerminal") + .HasFilter("[TerminalAtUtc] IS NULL"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("CreatedAtUtc"), new[] { "SourceSite", "SourceNode", "Status" }); + + b.HasIndex("TerminalAtUtc") + .HasDatabaseName("IX_SiteCalls_Terminal") + .HasFilter("[TerminalAtUtc] IS NOT NULL"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.HasIndex("DeployedAt", "Id") + .IsDescending() + .HasDatabaseName("IX_DeploymentRecords_DeployedAt_Id"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("InstanceId"); + + b.ToTable("PendingDeployments", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiRollupHourly", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("HourStartUtc") + .HasColumnType("datetime2"); + + b.Property("MaxValue") + .HasColumnType("float"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("MinValue") + .HasColumnType("float"); + + b.Property("SampleCount") + .HasColumnType("int"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("HourStartUtc") + .HasDatabaseName("IX_KpiRollupHourly_Hour"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "HourStartUtc") + .IsUnique() + .HasDatabaseName("IX_KpiRollupHourly_Series"); + + b.ToTable("KpiRollupHourly", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi.KpiSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CapturedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ScopeKey") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Value") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Captured"); + + b.HasIndex("Source", "Metric", "Scope", "ScopeKey", "CapturedAtUtc") + .HasDatabaseName("IX_KpiSample_Series"); + + b.ToTable("KpiSample", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("DeliveredAt") + .HasDatabaseName("IX_Notifications_Delivered") + .HasFilter("[Status] = 'Delivered'"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmsConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountSid") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ApiBaseUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("AuthToken") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("FromNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("MessagingServiceSid") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.ToTable("SmsConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("OAuth2Authority") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("OAuth2Scope") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Transport") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas.SharedSchema", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SchemaJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedSchemas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites.PendingSecuredWrite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("DecidedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ExecutionError") + .HasMaxLength(2048) + .IsUnicode(false) + .HasColumnType("varchar(2048)"); + + b.Property("OperatorComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("OperatorUser") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("SiteId") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("datetime2"); + + b.Property("TagPath") + .IsRequired() + .HasMaxLength(512) + .IsUnicode(false) + .HasColumnType("varchar(512)"); + + b.Property("ValueJson") + .IsRequired() + .HasMaxLength(4000) + .IsUnicode(false) + .HasColumnType("varchar(4000)"); + + b.Property("ValueType") + .IsRequired() + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("VerifierComment") + .HasMaxLength(1024) + .IsUnicode(false) + .HasColumnType("varchar(1024)"); + + b.Property("VerifierUser") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("SiteId") + .HasDatabaseName("IX_PendingSecuredWrites_Site"); + + b.HasIndex("Status", "SubmittedAtUtc") + .HasDatabaseName("IX_PendingSecuredWrites_Status_Submitted"); + + b.ToTable("PendingSecuredWrites", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }, + new + { + Id = 5, + LdapGroupName = "SCADA-Viewers", + Role = "Viewer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ElementDataType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport.BundleUnlockAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ClientKey") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("ClientKey", "AttemptedAtUtc") + .HasDatabaseName("IX_BundleUnlockAttempt_Client_Attempted"); + + b.ToTable("BundleUnlockAttempt", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasColumnName("Category"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)", true); + + b.Property("IngestedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime2(7)") + .HasComputedColumnSql("CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7))", false); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.kind')", true); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .IsUnicode(false) + .HasColumnType("varchar(16)"); + + b.Property("ParentExecutionId") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("uniqueidentifier") + .HasComputedColumnSql("CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)", true); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSiteId") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.sourceSiteId')", true); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)") + .HasComputedColumnSql("JSON_VALUE(DetailsJson,'$.status')", true); + + b.Property("Target") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("OccurredAtUtc"), new[] { "Status" }); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.PendingDeployment", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815165452_AddDeploymentRecordListSortIndex.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815165452_AddDeploymentRecordListSortIndex.cs new file mode 100644 index 00000000..2ad4759c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260815165452_AddDeploymentRecordListSortIndex.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + /// Replaces the single-column IX_DeploymentRecords_DeployedAt with the + /// composite IX_DeploymentRecords_DeployedAt_Id (DeployedAt DESC, Id DESC), + /// which is the deployment list's exact sort key (deferred-work register row 35). + /// + /// + /// The list read (DeploymentManagerRepository.QueryDeploymentListPageAsync) + /// joins Instance to make SiteId filterable, then orders + /// DeployedAt DESC, Id DESC 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: DeployedAt 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 Id 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 Id 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. SystemArtifactDeploymentRecords has its own separate + /// DeployedAt index and is deliberately untouched. + /// + public partial class AddDeploymentRecordListSortIndex : Migration + { + /// + 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]); + } + + /// + 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"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index a5d3b17f..2cb7efa9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -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"); }); From b2cab59cb9ac982bdd5566765672585ade449d9b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 13:22:41 -0400 Subject: [PATCH 3/3] feat(cli): compact table projection for `audit-config query` (register row 57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command passed no `tableProjector`, so `--format table` handed the renderer an `AuditLogQueryResult` OBJECT. That takes the generic Property/Value branch, which printed the entire `entries` array as raw JSON inside a single cell. JSON output was and remains correct, and is the primary format for this command. Adds `AuditConfigTableProjection`, mirroring `TemplateTableProjection`: string->string over the raw body, applied only on the table path, returning the input verbatim when it is not JSON or not a recognised shape so the renderer's own fallbacks still apply. It unwraps the envelope to a row array — id, timestamp, user, action, entityType, entityName, entityId — and drops `afterStateJson`, the full post-change entity snapshot, which is the audit analogue of template's fat child arrays: one per row fills a terminal. `bundleImportId` is dropped as mostly-null noise. `totalCount` would be lost by a plain unwrap, so a truncated page gains a single trailing row with a `note` cell ("showing 50 of 137 — use --page / --page-size for the rest"). When the page IS the whole result set the column is not emitted at all, so a small query's table stays exactly as narrow as its data, and the note row carries no `id` so it cannot be mistaken for an entry. A `--detail` flag skips the projection and renders the raw payload, matching `template list` / `template get` — that plus `--format json` is how to reach `afterStateJson`. A bare array root is also accepted, so the projection does not regress if the payload is ever unwrapped upstream or the deprecated `audit-log` alias meets an older server. Tests build their fixtures by serialising the REAL AuditLogQueryResult / AuditLogEntry types, so renaming a server property fails the test instead of silently blanking a column. CLI README + Component-CLI.md document the table shape, the omitted fields, and --detail. --- docs/requirements/Component-CLI.md | 12 +- .../Commands/AuditConfigTableProjection.cs | 153 +++++++++++++++ .../Commands/AuditLogCommands.cs | 12 +- src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 21 +- .../AuditConfigTableProjectionTests.cs | 182 ++++++++++++++++++ 5 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index 1b209303..068cfb16 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -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 ] [--entity-type ] [--action ] [--from ] [--to ] [--page ] [--page-size ] +scadabridge audit-config query [--user ] [--entity-type ] [--action ] [--from ] [--to ] [--page ] [--page-size ] [--detail] ``` The response is an `AuditLogQueryResult` — `{ "entries": [ … ], "totalCount": }`, 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 diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs new file mode 100644 index 00000000..f4f3aa8c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditConfigTableProjection.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; + +/// +/// Compact table projection for audit-config query (deferred-work register row 57). +/// +/// +/// The handler returns an AuditLogQueryResult — { "entries": [ … ], +/// "totalCount": N }. Handed to the generic renderer unprojected, that object takes +/// the Property/Value branch and the whole entries array lands in a single cell as +/// raw JSON, so --format table was unreadable. This unwraps the envelope to the +/// row array the renderer expects and drops afterStateJson, the per-entry snapshot +/// of the entire changed entity — the same "one fat field makes the table useless" problem +/// solves by dropping child collections. +/// +/// +/// +/// totalCount would be lost by a plain unwrap, so when the page is a strict subset +/// of the matches a single trailing row carries a note 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. +/// +/// +/// +/// JSON output is never touched (this runs only on the table path), and the command's +/// --detail flag skips the projection to render the raw payload as-is — that is +/// the way to see afterStateJson in table form. +/// +/// +internal static class AuditConfigTableProjection +{ + /// + /// Projects an AuditLogQueryResult 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. + /// + /// The raw success JSON body from the management API. + /// Compact JSON suitable for table rendering. + 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; + } + + /// Projects a single audit entry to its compact row node. + 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; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditLogCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditLogCommands.cs index 8fa8948b..b2b20892 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditLogCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditLogCommands.cs @@ -75,6 +75,13 @@ public static class AuditLogCommands pageOption.DefaultValueFactory = _ => 1; var pageSizeOption = new Option("--page-size") { Description = "Page size" }; pageSizeOption.DefaultValueFactory = _ => 50; + var detailOption = new Option("--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; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 0f94170a..dce85eee 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -1801,6 +1801,7 @@ scadabridge --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). --- diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs new file mode 100644 index 00000000..7e469999 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/AuditConfigTableProjectionTests.cs @@ -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; + +/// +/// Tests for the compact audit-config query table projection (deferred-work +/// register row 57). The command passed no tableProjector, so the generic +/// renderer saw an OBJECT, took its Property/Value +/// branch, and printed the entire entries array as raw JSON inside one cell. +/// The projection unwraps the envelope to a row array, drops the per-entry +/// afterStateJson snapshot, and keeps totalCount visible as a trailing note. +/// +public class AuditConfigTableProjectionTests +{ + /// + /// The real server shape, serialised from the REAL + /// / types rather than hand-written JSON, so renaming one + /// of their properties fails this test instead of silently blanking a column. + /// + 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(), 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 = "proxy error"; + 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})."); + } +}