@page "/operations/secured-writes" @* arch-review UA1: the Secured Writes history exposes process-sensitive tag values, so the page is gated to the Secured Write roles + Administrator (any-of), aligning with the ManagementActor ListSecuredWrites gate. *@ @attribute [Authorize(Policy = AuthorizationPolicies.RequireSecuredWriteAccess)] @using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management @using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol @using ZB.MOM.WW.ScadaBridge.Security @inject ISecuredWriteService SecuredWriteSvc @inject ISiteRepository SiteRepository @inject AuthenticationStateProvider AuthStateProvider @inject IDialogService Dialog @inject ILogger Logger @* Secured Writes. Two-person workflow: an Operator submits a write against an MxGateway connection; a DIFFERENT Verifier approves (which relays the write to the device) or rejects. This page only SUBMITS commands — the server (ManagementActor) enforces roles, the no-self-approval guard, the device relay, and the audit trail. Dev caveat: with DisableLogin on, one identity holds every role, so both role regions render; approve/reject on a row this user submitted is disabled (the server rejects it too, but the UI surfaces it up front). *@

Secured Writes

@* ── Operator region: submit form ───────────────────────────────────── *@
Submit a secured write (Operator)
@* ── Verifier region: pending queue ─────────────────────────────────── *@
Pending verification (Verifier)
@if (_pending.Count == 0) {

No pending secured writes.

} else { @foreach (var row in _pending) { var isOwn = string.Equals(row.OperatorUser, _currentUsername, StringComparison.OrdinalIgnoreCase); @* Tag path and value are device/operator supplied and unbounded. .cell-clip is display:block, so it goes on an inner element — putting it on the }
Site Connection Tag Value Type Operator Submitted Actions
@row.SiteId @row.ConnectionName would drop the cell out of the row. *@ @row.TagPath @row.ValueJson @row.ValueType @row.OperatorUser @row.SubmittedAtUtc.ToString("u") @if (isOwn) { your submission }
}
@* ── History region: terminal rows (any authenticated user) ─────────── *@
History (decided / executed)
@if (_history.Count == 0) {

No completed secured writes.

} else { @foreach (var row in _history) { @* 11 columns here, and the three timestamps to the right are text-nowrap — so the free-text cells clip narrow (-sm). *@ @* Raw device/MxGateway exception text in the last column — clamp to two lines so one bad message can't set the whole table's width. *@ }
Site Connection Tag Value Status Operator Verifier Submitted Decided Executed Error
@row.SiteId @row.ConnectionName@row.TagPath @row.ValueJson @row.Status @row.OperatorUser @(row.VerifierUser ?? "—") @row.SubmittedAtUtc.ToString("u") @(row.DecidedAtUtc?.ToString("u") ?? "—") @(row.ExecutedAtUtc?.ToString("u") ?? "—") @(row.ExecutionError ?? "")
} @if (_historyTotalCount > HistoryPageSize) {
Showing @(_historyPage * HistoryPageSize + 1)–@Math.Min((_historyPage + 1) * HistoryPageSize, _historyTotalCount) of @_historyTotalCount
}
@code { private ToastNotification _toast = default!; private IReadOnlyList _sites = Array.Empty(); private readonly Dictionary> _connectionsBySite = new(StringComparer.Ordinal); private List _pending = new(); 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 = ""; private string _formTagPath = ""; private string _formValue = ""; private string _formValueType = nameof(DataType.Boolean); private string _formComment = ""; private NodeBrowserDialog? _browserRef; private bool CanBrowse => !string.IsNullOrWhiteSpace(_formSiteIdentifier) && !string.IsNullOrWhiteSpace(_formConnectionName); private async Task OpenBrowser() { if (!CanBrowse || _browserRef is null) return; await _browserRef.ShowAsync(_formSiteIdentifier, _formConnectionName, _formTagPath); } private void OnTagPicked(BrowseNode node) { _formTagPath = node.NodeId; if (SecuredWriteDataTypeMapper.TryMap(node.DataType, out var dt)) _formValueType = dt.ToString(); } private bool _submitting; private bool _actionInProgress; /// Terminal statuses surfaced in the History table. private static readonly string[] TerminalStatuses = ["Executed", "Failed", "Rejected", "Expired"]; private IReadOnlyList MxConnectionsForForm => string.IsNullOrEmpty(_formSiteIdentifier) || !_connectionsBySite.TryGetValue(_formSiteIdentifier, out var conns) ? Array.Empty() : conns.Where(c => string.Equals(c.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase)).ToList(); private bool CanSubmit => !string.IsNullOrWhiteSpace(_formSiteIdentifier) && !string.IsNullOrWhiteSpace(_formConnectionName) && !string.IsNullOrWhiteSpace(_formTagPath) && !string.IsNullOrWhiteSpace(_formValue); protected override async Task OnInitializedAsync() { _currentUsername = await AuthStateProvider.GetCurrentUsernameAsync(); await LoadSitesAsync(); await RefreshListsAsync(); } private async Task LoadSitesAsync() { try { _sites = await SiteRepository.GetAllSitesAsync(); foreach (var site in _sites) { _connectionsBySite[site.SiteIdentifier] = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id); } } catch (Exception ex) { Logger.LogError(ex, "Failed to load sites/connections for the Secured Writes form."); _toast.ShowError("Failed to load sites."); } } private async Task RefreshListsAsync() { // 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() { if (!CanSubmit) return; _submitting = true; try { var result = await SecuredWriteSvc.SubmitAsync( _formSiteIdentifier, _formConnectionName, _formTagPath, _formValue, _formValueType, string.IsNullOrWhiteSpace(_formComment) ? null : _formComment); if (result.Success) { _toast.ShowSuccess("Secured write submitted for verification."); _formTagPath = ""; _formValue = ""; _formComment = ""; await RefreshListsAsync(); } else { _toast.ShowError(result.Error ?? "Submit failed."); } } finally { _submitting = false; } } private async Task Approve(SecuredWriteDto row) { var confirmed = await Dialog.ConfirmAsync( "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})\n" + $"Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})\n" + $"Operator: {row.OperatorUser}", // Caution, NOT danger (deferred-work register row 75). Approving executes a // real write to live plant equipment, so this is not a benign primary action // — but it is not destroying anything either, and red made it look like a // delete while rendering it identical to the Reject button right beside it. // Amber keeps the beat of hesitation the write deserves and restores the // colour contrast between the two opposite decisions. DialogSeverity.Caution, confirmLabel: "Approve"); if (!confirmed) return; _actionInProgress = true; try { var result = await SecuredWriteSvc.ApproveAsync(row.Id, comment: null); if (result.Success) { var status = result.Dto?.Status ?? "decided"; if (string.Equals(status, "Executed", StringComparison.OrdinalIgnoreCase)) { _toast.ShowSuccess("Secured write approved and executed."); } else { _toast.ShowWarning($"Secured write approved but not executed (status: {status})."); } await RefreshListsAsync(); } else { _toast.ShowError(result.Error ?? "Approve failed."); } } finally { _actionInProgress = false; } } private async Task Reject(SecuredWriteDto row) { var confirmed = await Dialog.ConfirmAsync( "Reject secured write", $"Reject the secured write to {row.SiteId}/{row.ConnectionName}/{row.TagPath}?", // Reject KEEPS danger styling (register row 75). It is terminal and // irreversible for the request — the Operator has to resubmit — and holding // it red is what makes the pair readable: with Approve amber, the two // opposite decisions no longer look alike. Dropping Reject to a neutral // button would just invert the bias, making declining look like the safe // default. danger: true, confirmLabel: "Reject"); if (!confirmed) return; _actionInProgress = true; try { var result = await SecuredWriteSvc.RejectAsync(row.Id, comment: null); if (result.Success) { _toast.ShowSuccess("Secured write rejected."); await RefreshListsAsync(); } else { _toast.ShowError(result.Error ?? "Reject failed."); } } finally { _actionInProgress = false; } } private static string StatusBadgeClass(string status) => status switch { "Executed" => "text-bg-success", "Failed" => "text-bg-danger", "Rejected" => "text-bg-secondary", "Expired" => "text-bg-secondary", _ => "text-bg-light" }; }