ui: Central UI density/consistency sweep + Theme 0.4.1

Applies the family-wide admin-UI cleanup playbook to the Central UI so the
Blazor surfaces stop diverging from the shared kit: buttons are grouped rather
than individually sized, long cell values are contained instead of widening
tables, and hard-coded colours give way to theme tokens.

The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell,
which the kit emits as an inline style on the shell root. Being a descendant of
<html>, it beat the [data-bs-theme="dark"] override for the entire app, so the
dark accent had never rendered. Declaring --accent in site.css :root instead
lets both schemes resolve; light is unchanged because the value already matched
the kit's light default.

Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so
that block is deleted here rather than duplicated. Verified byte-identical
before removal; the repo now declares no --bs-btn-* anywhere.

NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal
surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages,
SiteCallsReport) were additionally refactored from holding the selected record
to holding its id and re-resolving from the current page each render, with the
resolve doubling as the visibility gate. A background refresh that drops the
row now closes the modal instead of showing a stale snapshot. This is a
behaviour change and is called out rather than buried: a full-suite run turned
up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack
(GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler
was disposed between render and click — a window the previous field-held record
made structurally impossible. Treat the modal lifecycle here as unreviewed.

Build 0/0; suite green apart from that one intermittent failure.
This commit is contained in:
Joseph Doherty
2026-08-11 05:50:12 -04:00
parent b6f383a225
commit 9e243493fb
77 changed files with 2197 additions and 1292 deletions
@@ -115,16 +115,20 @@
{
<div class="alert alert-secondary py-2 d-flex align-items-center mb-3">
<strong class="me-3">@_selectedIds.Count selected</strong>
<button class="btn btn-outline-success btn-sm me-2"
@onclick="BulkRetry" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry selected
</button>
<button class="btn btn-outline-danger btn-sm me-2"
@onclick="BulkDiscard" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard selected
</button>
<div class="btn-group btn-group-sm" role="group" aria-label="Bulk actions">
<button class="btn btn-outline-success"
@onclick="BulkRetry" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry selected
</button>
<button class="btn btn-outline-danger"
@onclick="BulkDiscard" disabled="@_bulkInProgress">
@if (_bulkInProgress && _bulkAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard selected
</button>
</div>
@* The dismiss control is not a bulk action — it stays outside the group so
it keeps its ms-auto right-alignment and its btn-close styling. *@
<button type="button" class="btn-close ms-auto"
aria-label="Clear selection" @onclick="ClearSelection"></button>
</div>
@@ -236,10 +240,10 @@
<span class="text-muted small">
Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total
</span>
<div>
<button class="btn btn-outline-secondary btn-sm me-1"
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
<button class="btn btn-outline-secondary"
@onclick="PrevPage" disabled="@(_pageNumber <= 1)">Previous</button>
<button class="btn btn-outline-secondary btn-sm"
<button class="btn btn-outline-secondary"
@onclick="NextPage" disabled="@(_messages.Count < _pageSize)">Next</button>
</div>
</div>
@@ -247,15 +251,19 @@
}
</div>
@if (_drawerMessage != null)
@* Re-resolved from the CURRENT page every render (see DrawerMessage). A row that
has been retried, discarded or paged away simply resolves to null and the drawer
renders nothing — it can never keep showing a row that no longer exists. *@
@{ var drawer = DrawerMessage; }
@if (drawer != null)
{
<div class="offcanvas-backdrop fade show" @onclick="CloseDrawer"></div>
<div class="offcanvas offcanvas-end show parked-drawer" tabindex="-1" style="visibility: visible;">
<div class="offcanvas-header border-bottom">
<div>
<div class="text-muted small text-uppercase">Parked message</div>
<h5 class="offcanvas-title mb-0">@_drawerMessage.TargetSystem</h5>
<div class="small text-muted">@_drawerMessage.MethodName</div>
<h5 class="offcanvas-title mb-0">@drawer.TargetSystem</h5>
<div class="small text-muted">@drawer.MethodName</div>
</div>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseDrawer"></button>
</div>
@@ -263,17 +271,17 @@
<dl class="row mb-3">
<dt class="col-4 text-muted fw-normal">Message ID</dt>
<dd class="col-8 d-flex align-items-center gap-2">
<code class="text-truncate" style="min-width: 0;">@_drawerMessage.MessageId</code>
<code class="text-truncate" style="min-width: 0;">@drawer.MessageId</code>
<button class="btn btn-link btn-sm p-0" title="Copy message ID"
@onclick="() => CopyAsync(_drawerMessage.MessageId)">📋</button>
@onclick="() => CopyAsync(drawer.MessageId)">📋</button>
</dd>
<dt class="col-4 text-muted fw-normal">Category</dt>
<dd class="col-8">@CategoryLabel(_drawerMessage.Category)</dd>
<dd class="col-8">@CategoryLabel(drawer.Category)</dd>
<dt class="col-4 text-muted fw-normal">Origin instance</dt>
<dd class="col-8">
@if (!string.IsNullOrEmpty(_drawerMessage.OriginInstance))
@if (!string.IsNullOrEmpty(drawer.OriginInstance))
{
<code>@_drawerMessage.OriginInstance</code>
<code>@drawer.OriginInstance</code>
}
else
{
@@ -281,21 +289,21 @@
}
</dd>
<dt class="col-4 text-muted fw-normal">Attempts</dt>
<dd class="col-8 font-monospace">@_drawerMessage.AttemptCount / @_drawerMessage.MaxAttempts</dd>
<dd class="col-8 font-monospace">@drawer.AttemptCount / @drawer.MaxAttempts</dd>
<dt class="col-4 text-muted fw-normal">Originally enqueued</dt>
<dd class="col-8">
@Relative(_drawerMessage.OriginalTimestamp)
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.OriginalTimestamp)</span>
@Relative(drawer.OriginalTimestamp)
<span class="text-muted">· @AbsoluteUtc(drawer.OriginalTimestamp)</span>
</dd>
<dt class="col-4 text-muted fw-normal">Last attempt</dt>
<dd class="col-8">
@Relative(_drawerMessage.LastAttemptTimestamp)
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.LastAttemptTimestamp)</span>
@Relative(drawer.LastAttemptTimestamp)
<span class="text-muted">· @AbsoluteUtc(drawer.LastAttemptTimestamp)</span>
</dd>
</dl>
<div class="text-muted text-uppercase small fw-semibold mb-1">Error</div>
<pre class="bg-body-secondary border rounded p-2 small mb-0 parked-error-pre">@_drawerMessage.ErrorMessage</pre>
<pre class="bg-body-secondary border rounded p-2 small mb-0 parked-error-pre">@drawer.ErrorMessage</pre>
</div>
<div class="border-top p-3 d-flex gap-2">
<button class="btn btn-outline-success btn-sm flex-grow-1"
@@ -355,8 +363,21 @@
private bool _actionInProgress;
private string? _activeAction;
// Drawer
private ParkedMessageEntry? _drawerMessage;
// Drawer — holds the row's ID, never the row object. FetchPage() replaces
// _messages wholesale after every Retry/Discard and on every page change, so a
// captured ParkedMessageEntry would outlive its row and keep rendering values
// (attempt count, last-attempt time, error) that no longer exist server-side;
// PrevPage/NextPage do not clear the drawer, so it could even sit open over a
// page that never contained it. Mirrors ExecutionTreePage's _modalExecutionId.
private string? _drawerMessageId;
// Re-resolves the open drawer's row from the CURRENT page on every render.
// A row that has been retried, discarded or paged away resolves to null, which
// renders the drawer away — the drawer self-closes rather than going stale.
private ParkedMessageEntry? DrawerMessage =>
_drawerMessageId is null
? null
: _messages?.FirstOrDefault(m => m.MessageId == _drawerMessageId);
private ToastNotification _toast = default!;
@@ -384,7 +405,7 @@
{
_messages = null;
_selectedIds.Clear();
_drawerMessage = null;
_drawerMessageId = null;
}
}
@@ -392,7 +413,7 @@
{
_pageNumber = 1;
_selectedIds.Clear();
_drawerMessage = null;
_drawerMessageId = null;
await FetchPage();
}
@@ -551,21 +572,23 @@
// ── Drawer ──
private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessage = msg;
private void CloseDrawer() => _drawerMessage = null;
private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessageId = msg.MessageId;
private void CloseDrawer() => _drawerMessageId = null;
private async Task RetryFromDrawer()
{
if (_drawerMessage == null) return;
var msg = _drawerMessage;
// Snapshot the resolved row before acting — RetrySingle awaits FetchPage(),
// after which DrawerMessage no longer resolves.
var msg = DrawerMessage;
if (msg == null) return;
await RetrySingle(msg);
CloseDrawer();
}
private async Task DiscardFromDrawer()
{
if (_drawerMessage == null) return;
var msg = _drawerMessage;
var msg = DrawerMessage;
if (msg == null) return;
var ok = await DiscardSingle(msg);
if (ok) CloseDrawer();
}