diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index eaeaefe5..64ed9251 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -211,8 +211,8 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms - A **Secured Writes** page (`/operations/secured-writes`) drives the **two-person** authorization workflow for writes through the MxAccess Gateway: an **Operator** initiates the write, a separate **Verifier** approves it, and only an approved write reaches the site. - The **page itself is gated by `RequireSecuredWriteAccess`** — satisfied by any of `Operator` / `Verifier` / `Administrator`. Although the pending/history lists are read-only, they expose process-sensitive tag values, so the page is not open to every authenticated user; this aligns with the ManagementActor `ListSecuredWritesCommand` any-of gate (arch-review UA1). The submit and approve/reject sub-actions are further gated by `RequireOperator` / `RequireVerifier` respectively (below). - **Operator (submit)** — a submit form gated by `RequireOperator`: pick the site → an **MxGateway** connection on that site → the tag path → a typed value → an optional comment. Submission inserts a `Pending` `PendingSecuredWrite` row centrally; it does **not** write anything yet. -- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing. The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason. -- **History** — terminal rows (Executed / Failed / Rejected / Expired) with the full who/when/outcome trail (operator, verifier, comments, timestamps, any execution error). +- **Verifier (approve / reject)** — a pending queue gated by `RequireVerifier` with **Approve** / **Reject** (+comment) actions. Approve shows a confirmation of the exact site / connection / tag / value before firing, and surfaces the **submission age** (`Submitted ago ()`) so a verifier can catch a stale setpoint before it reaches the device. The verifier's **own submissions are disabled in the UI and rejected server-side** (no self-approval). On approve, central marks the row `Approved` and relays the write to the site MxGateway (records `Executed` / `Failed`); reject moves it to `Rejected` with a reason. The pending queue is **unpaged** — it is bounded by the submission **TTL**: a `Pending` row that is neither approved nor rejected before the TTL elapses expires to the terminal **`Expired`** status and drops out of the queue into History. +- **History** — terminal rows (Executed / Failed / Rejected / **Expired**, where `Expired` is a real TTL-driven terminal status) with the full who/when/outcome trail (operator, verifier, comments, timestamps, any execution error). Terminal rows accumulate without bound, so the History table is **offset-paged** (page-size 50, Prev/Next) driven by the server's unpaged `TotalCount` (`ListSecuredWritesCommand` `Skip`/`Take`); the pager buttons disable at the real bounds. - Every lifecycle event (submit / approve / reject / execute) is written to the central Audit Log; the rows share the `PendingSecuredWrite.Id` as `CorrelationId` so they join into one operation (see Component-ManagementService.md, Component-AuditLog.md). - **Dev caveat**: with `DisableLogin` on, the auto-login identity holds all roles, so the two-person flow cannot be exercised end-to-end by a single user via the dev UI — no-self-approval is covered by handler tests; real two-person use requires two real identities. 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 f99a9e58..9e85ce2f 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 @@ -221,6 +221,22 @@ } + @if (_historyTotalCount > HistoryPageSize) + { +
+ + Showing @(_historyPage * HistoryPageSize + 1)–@Math.Min((_historyPage + 1) * HistoryPageSize, _historyTotalCount) + of @_historyTotalCount + +
+ + +
+
+ } @@ -234,6 +250,18 @@ private List _history = new(); private string _currentUsername = ClaimsPrincipalExtensions.UnknownUser; + // History pager. Terminal rows can accumulate without bound, so the History table is + // offset-paged (page-size 50); the Pending queue stays unpaged because it is now + // bounded by the submission TTL (arch-review S2). _historyTotalCount is the server's + // unpaged match count, so Prev/Next disable at the real bounds. + private const int HistoryPageSize = 50; + // Pending is bounded by the TTL; fetch a generous cap in one unpaged read. + private const int PendingFetchLimit = 500; + private int _historyPage; // 0-based + private int _historyTotalCount; + + private bool HasNextHistoryPage => (_historyPage + 1) * HistoryPageSize < _historyTotalCount; + // Submit-form state. private string _formSiteIdentifier = ""; private string _formConnectionName = ""; @@ -305,9 +333,46 @@ private async Task RefreshListsAsync() { - var all = await SecuredWriteSvc.ListAsync(status: null, siteId: null); - _pending = all.Where(w => string.Equals(w.Status, "Pending", StringComparison.OrdinalIgnoreCase)).ToList(); - _history = all.Where(w => TerminalStatuses.Contains(w.Status, StringComparer.OrdinalIgnoreCase)).ToList(); + // Pending queue: unpaged (bounded by the submission TTL), filtered to Pending. + var pending = await SecuredWriteSvc.ListAsync( + status: "Pending", siteId: null, skip: 0, take: PendingFetchLimit); + _pending = pending.Items + .Where(w => string.Equals(w.Status, "Pending", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // History: terminal rows, offset-paged. TotalCount drives the pager bounds. + var history = await SecuredWriteSvc.ListAsync( + status: null, siteId: null, skip: _historyPage * HistoryPageSize, take: HistoryPageSize); + _history = history.Items + .Where(w => TerminalStatuses.Contains(w.Status, StringComparer.OrdinalIgnoreCase)) + .ToList(); + _historyTotalCount = history.TotalCount; + } + + private async Task HistoryNext() + { + if (!HasNextHistoryPage) return; + _historyPage++; + await RefreshListsAsync(); + } + + private async Task HistoryPrev() + { + if (_historyPage == 0) return; + _historyPage--; + await RefreshListsAsync(); + } + + /// Renders a coarse, human-readable age for a submission timestamp. + private static string FormatAge(DateTime submittedUtc) + { + var age = DateTime.UtcNow - submittedUtc; + if (age < TimeSpan.Zero) age = TimeSpan.Zero; + if (age.TotalMinutes < 1) return "less than a minute"; + if (age.TotalHours < 1) { var m = (int)age.TotalMinutes; return $"{m} minute{(m == 1 ? "" : "s")}"; } + if (age.TotalDays < 1) { var h = (int)age.TotalHours; return $"{h} hour{(h == 1 ? "" : "s")}"; } + var d = (int)age.TotalDays; + return $"{d} day{(d == 1 ? "" : "s")}"; } private async Task Submit() @@ -346,7 +411,9 @@ "Approve secured write", $"Approving will write to the device.\n\n" + $"Site: {row.SiteId}\nConnection: {row.ConnectionName}\nTag: {row.TagPath}\n" + - $"Value: {row.ValueJson} ({row.ValueType})\nOperator: {row.OperatorUser}", + $"Value: {row.ValueJson} ({row.ValueType})\n" + + $"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" + + $"Operator: {row.OperatorUser}", danger: true); if (!confirmed) return; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs index 952951ad..4aa87f6f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs @@ -92,13 +92,18 @@ public interface ISecuredWriteService /// /// Lists secured writes, optionally filtered by and - /// (read-only — any authenticated user). On any failure - /// an empty list is returned so the page renders gracefully. + /// with offset paging (read-only — any authenticated user). + /// On any failure an empty result (TotalCount = 0) is returned so the page + /// renders gracefully. The returned is + /// the unpaged match count, so the page can drive a pager independent of page size. /// /// Status filter; null matches every status. /// Site id filter; null matches every site. + /// Rows to skip (offset paging); clamped to >= 0 server-side. + /// Maximum rows to return; clamped to 1..500 server-side. /// Cancellation token. - /// A task resolving to the matching rows, newest submission first. - Task> ListAsync( - string? status, string? siteId, CancellationToken cancellationToken = default); + /// A task resolving to the matching page + total match count, newest submission first. + Task ListAsync( + string? status, string? siteId, int skip = 0, int take = 200, + CancellationToken cancellationToken = default); } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SecuredWriteService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SecuredWriteService.cs index b794d209..102ed261 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SecuredWriteService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/SecuredWriteService.cs @@ -79,21 +79,23 @@ public sealed class SecuredWriteService : ISecuredWriteService => DispatchAsync(new RejectSecuredWriteCommand(id, comment), cancellationToken); /// - public async Task> ListAsync( - string? status, string? siteId, CancellationToken cancellationToken = default) + public async Task ListAsync( + string? status, string? siteId, int skip = 0, int take = 200, + CancellationToken cancellationToken = default) { - var response = await SendAsync(new ListSecuredWritesCommand(status, siteId), cancellationToken); + var response = await SendAsync( + new ListSecuredWritesCommand(status, siteId, skip, take), cancellationToken); if (response is ManagementSuccess success) { var result = JsonSerializer.Deserialize( success.JsonData, ResultDeserializerOptions); - return result?.Items ?? Array.Empty(); + return result ?? new SecuredWriteListResult(Array.Empty(), 0); } // Read path: log + return empty so the queue/history tables render gracefully. _logger.LogWarning( "ListSecuredWrites failed: {Response}", DescribeFailure(response)); - return Array.Empty(); + return new SecuredWriteListResult(Array.Empty(), 0); } /// 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 5352a2f6..a1495c5d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/SecuredWritesTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using System.Text.RegularExpressions; using Bunit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; @@ -48,10 +49,17 @@ public class SecuredWritesTests : BunitContext .Returns(Task.FromResult(true)); // Empty list by default; individual tests seed the queue/history. - _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult>(Array.Empty())); + _service.ListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new SecuredWriteListResult(Array.Empty(), 0))); } + /// Stubs ListAsync (any filter/paging args) to return . + private void SeedList(IReadOnlyList rows, int? totalCount = null) + => _service.ListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new SecuredWriteListResult(rows, totalCount ?? rows.Count))); + /// /// Registers the real authorization stack + a principal with the given username and /// roles so the page's AuthorizeView Policy=... regions evaluate genuinely. @@ -165,9 +173,7 @@ public class SecuredWritesTests : BunitContext { AddAuth("verifier-user", Roles.Verifier); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); - _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult>( - new[] { Dto(1, "Pending", "someone-else") })); + SeedList(new[] { Dto(1, "Pending", "someone-else") }); var cut = RenderPage(); @@ -189,9 +195,7 @@ public class SecuredWritesTests : BunitContext // this too, but the UI surfaces it up front). AddAuth("self", Roles.Operator, Roles.Verifier); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); - _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult>( - new[] { Dto(1, "Pending", "self") })); + SeedList(new[] { Dto(1, "Pending", "self") }); var cut = RenderPage(); @@ -210,9 +214,7 @@ public class SecuredWritesTests : BunitContext { AddAuth("verifier-user", Roles.Verifier); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); - _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult>( - new[] { Dto(7, "Pending", "someone-else") })); + SeedList(new[] { Dto(7, "Pending", "someone-else") }); _service.ApproveAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(7, "Executed", "someone-else", "verifier-user")))); @@ -229,14 +231,13 @@ public class SecuredWritesTests : BunitContext { AddAuth("viewer", Roles.Viewer); SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); - _service.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult>(new[] - { - Dto(1, "Executed", "op-a", "ver-a", tag: "Pump.Setpoint"), - Dto(2, "Rejected", "op-b", "ver-b", tag: "Valve.Cmd"), - Dto(3, "Failed", "op-c", "ver-c", executionError: "device timeout", tag: "Motor.Run"), - Dto(4, "Pending", "op-d", tag: "ShouldNotAppear"), - })); + SeedList(new[] + { + Dto(1, "Executed", "op-a", "ver-a", tag: "Pump.Setpoint"), + Dto(2, "Rejected", "op-b", "ver-b", tag: "Valve.Cmd"), + Dto(3, "Failed", "op-c", "ver-c", executionError: "device timeout", tag: "Motor.Run"), + Dto(4, "Pending", "op-d", tag: "ShouldNotAppear"), + }); var cut = RenderPage(); @@ -253,6 +254,58 @@ public class SecuredWritesTests : BunitContext }); } + // ── (d) History pager: page-size 50, driven by TotalCount ── + + [Fact] + public void History_Pager_NextRequestsSecondPage_UsingTotalCount() + { + // 60 terminal rows returned but TotalCount = 120 → a second page exists, so the + // "Next" button is present + enabled and clicking it re-queries with skip: 50. + AddAuth("viewer", Roles.Viewer); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + var terminal = Enumerable.Range(1, 60) + .Select(i => Dto(i, "Executed", $"op-{i}", $"ver-{i}")) + .ToArray(); + SeedList(terminal, totalCount: 120); + + var cut = RenderPage(); + + cut.WaitForAssertion(() => + { + var next = cut.Find("[data-test='secured-write-history-next']"); + Assert.False(next.HasAttribute("disabled")); + }); + + cut.Find("[data-test='secured-write-history-next']").Click(); + + // The history page-2 fetch offsets by one page (skip: 50). + _service.Received().ListAsync( + Arg.Any(), Arg.Any(), 50, Arg.Any(), Arg.Any()); + } + + // ── (e) Approve dialog surfaces submission age (stale-setpoint scenario) ── + + [Fact] + public void Approve_ConfirmDialog_ShowsSubmissionAge() + { + AddAuth("verifier-user", Roles.Verifier); + SeedSites(new Site("Plant-A", "plant-a") { Id = 1 }); + var stale = Dto(9, "Pending", "someone-else") with { SubmittedAtUtc = DateTime.UtcNow.AddHours(-26) }; + SeedList(new[] { stale }); + _service.ApproveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(SecuredWriteActionResult.Ok(Dto(9, "Executed", "someone-else", "verifier-user")))); + + var cut = RenderPage(); + cut.WaitForAssertion(() => Assert.NotNull(cut.Find("[data-test='secured-write-approve']"))); + + cut.Find("[data-test='secured-write-approve']").Click(); + + _dialog.Received(1).ConfirmAsync( + Arg.Any(), + Arg.Is(m => Regex.IsMatch(m, "Submitted .* ago")), + Arg.Any()); + } + [Fact] public void Viewer_DoesNotSeeOperatorOrVerifierRegions() {