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:
+71
-4
@@ -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 >= 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>
|
||||
|
||||
Reference in New Issue
Block a user