feat(ui): secured-write history paging + submission age in approve dialog (arch-review S2/P3)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:51:20 -04:00
parent 655834f0b8
commit d8a39f3c35
5 changed files with 162 additions and 35 deletions
@@ -221,6 +221,22 @@
</tbody>
</table>
}
@if (_historyTotalCount > HistoryPageSize)
{
<div class="d-flex justify-content-between align-items-center p-2 border-top small"
data-test="secured-write-history-pager">
<span class="text-muted">
Showing @(_historyPage * HistoryPageSize + 1)@Math.Min((_historyPage + 1) * HistoryPageSize, _historyTotalCount)
of @_historyTotalCount
</span>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" @onclick="HistoryPrev"
disabled="@(_historyPage == 0)" data-test="secured-write-history-prev">Prev</button>
<button class="btn btn-outline-secondary" @onclick="HistoryNext"
disabled="@(!HasNextHistoryPage)" data-test="secured-write-history-next">Next</button>
</div>
</div>
}
</div>
</div>
</div>
@@ -234,6 +250,18 @@
private List<SecuredWriteDto> _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();
}
/// <summary>Renders a coarse, human-readable age for a submission timestamp.</summary>
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;
@@ -92,13 +92,18 @@ public interface ISecuredWriteService
/// <summary>
/// Lists secured writes, optionally filtered by <paramref name="status"/> and
/// <paramref name="siteId"/> (read-only — any authenticated user). On any failure
/// an empty list is returned so the page renders gracefully.
/// <paramref name="siteId"/> with offset paging (read-only — any authenticated user).
/// On any failure an empty result (<c>TotalCount = 0</c>) is returned so the page
/// renders gracefully. The returned <see cref="SecuredWriteListResult.TotalCount"/> is
/// the unpaged match count, so the page can drive a pager independent of page size.
/// </summary>
/// <param name="status">Status filter; <c>null</c> matches every status.</param>
/// <param name="siteId">Site id filter; <c>null</c> matches every site.</param>
/// <param name="skip">Rows to skip (offset paging); clamped to &gt;= 0 server-side.</param>
/// <param name="take">Maximum rows to return; clamped to 1..500 server-side.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task resolving to the matching rows, newest submission first.</returns>
Task<IReadOnlyList<SecuredWriteDto>> ListAsync(
string? status, string? siteId, CancellationToken cancellationToken = default);
/// <returns>A task resolving to the matching page + total match count, newest submission first.</returns>
Task<SecuredWriteListResult> ListAsync(
string? status, string? siteId, int skip = 0, int take = 200,
CancellationToken cancellationToken = default);
}
@@ -79,21 +79,23 @@ public sealed class SecuredWriteService : ISecuredWriteService
=> DispatchAsync(new RejectSecuredWriteCommand(id, comment), cancellationToken);
/// <inheritdoc/>
public async Task<IReadOnlyList<SecuredWriteDto>> ListAsync(
string? status, string? siteId, CancellationToken cancellationToken = default)
public async Task<SecuredWriteListResult> 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<SecuredWriteListResult>(
success.JsonData, ResultDeserializerOptions);
return result?.Items ?? Array.Empty<SecuredWriteDto>();
return result ?? new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0);
}
// Read path: log + return empty so the queue/history tables render gracefully.
_logger.LogWarning(
"ListSecuredWrites failed: {Response}", DescribeFailure(response));
return Array.Empty<SecuredWriteDto>();
return new SecuredWriteListResult(Array.Empty<SecuredWriteDto>(), 0);
}
/// <summary>