refactor(ui/deployment): live-updates toggle, DebugView guardrails

New shared DiffDialog mirroring ConfirmDialog's API
(ShowAsync(title, before, after)) so live-data pages stop
hand-rolling Bootstrap modal markup.

Topology: <h4> in flex header, aria-labels on Expand/Collapse/Refresh
and the inline rename input, Live-updates toggle (suppresses the 15s
timer when off), instance/area counts moved into a summary alert
above the tree, Stale badge paired with bi-exclamation-triangle icon
+ aria-label, hand-rolled Diff modal replaced with <DiffDialog @ref>.

Deployments: pause/resume auto-refresh button replaces the static
"Auto-refresh: 10s" text; summary cards switch to
col-lg-3 col-md-6 col-12; InProgress spinner gets role="status" +
aria-label; failed rows pick up a bi-x-circle icon next to the
Status badge; Deployment ID + Revision folded into one
{id}@{revision[..8]} cell; inline Error column collapses behind a
per-row "View error" toggle; bare empty-state text upgraded to the
centered muted block.

DebugView: status-strip card at the top showing instance / connection
state / last snapshot timestamp plus a "Start fresh" button when the
page auto-reconnected from localStorage. Per-table filter input,
scroll-lock toggle, Clear button, and a 200-row queue-style cap.
<tbody> elements gain aria-live="polite" aria-atomic="false" for
screen-reader announcements. Quality and Alarm-State badges get
aria-labels; timestamps display HH:mm:ss with full ms in a hover
tooltip. Auto-reconnect surfaces a toast with autoDismissMs: 8000.
This commit is contained in:
Joseph Doherty
2026-05-12 03:32:53 -04:00
parent b6e2ec8a50
commit 321ca0bbbf
4 changed files with 541 additions and 127 deletions
@@ -12,9 +12,12 @@
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Deployment Status</h4>
<div>
<span class="text-muted small me-2">Auto-refresh: 10s</span>
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync">Refresh</button>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-outline-secondary btn-sm" @onclick="ToggleAutoRefresh"
aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
</div>
</div>
@@ -30,7 +33,7 @@
{
@* Summary cards *@
<div class="row mb-3">
<div class="col-md-3">
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-warning">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-warning">@_records.Count(r => r.Status == DeploymentStatus.Pending)</h4>
@@ -38,7 +41,7 @@
</div>
</div>
</div>
<div class="col-md-3">
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-info">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-info">@_records.Count(r => r.Status == DeploymentStatus.InProgress)</h4>
@@ -46,7 +49,7 @@
</div>
</div>
</div>
<div class="col-md-3">
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-success">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-success">@_records.Count(r => r.Status == DeploymentStatus.Success)</h4>
@@ -54,7 +57,7 @@
</div>
</div>
</div>
<div class="col-md-3">
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-danger">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-danger">@_records.Count(r => r.Status == DeploymentStatus.Failed)</h4>
@@ -64,37 +67,51 @@
</div>
</div>
<table class="table table-sm table-striped table-hover">
@if (_records.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-0">No deployments recorded.</p>
</div>
}
else
{
<table class="table table-sm table-striped table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Deployment ID</th>
<th>Deployment</th>
<th>Instance</th>
<th>Status</th>
<th>Deployed By</th>
<th>Started</th>
<th>Completed</th>
<th>Revision</th>
<th>Error</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@if (_records.Count == 0)
{
<tr>
<td colspan="8" class="text-muted text-center">No deployments recorded.</td>
</tr>
}
@foreach (var record in _pagedRecords)
{
<tr class="@GetRowClass(record.Status)">
<td><code class="small">@record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)]...</code></td>
var rowId = $"deploy-row-{record.DeploymentId}";
var errorCollapseId = $"deploy-err-{record.DeploymentId}";
var isFailed = record.Status == DeploymentStatus.Failed;
var idShort = record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)];
var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)];
<tr id="@rowId" class="@GetRowClass(record.Status)">
<td>
<code class="small">@idShort@(string.IsNullOrEmpty(revShort) ? "" : $"@{revShort}")</code>
</td>
<td>@GetInstanceName(record.InstanceId)</td>
<td>
<span class="badge @GetStatusBadge(record.Status)">
@if (isFailed)
{
<i class="bi bi-x-circle text-danger me-1" aria-hidden="true"></i>
}
<span class="badge @GetStatusBadge(record.Status)"
aria-label="@($"Deployment status: {record.Status}")">
@record.Status
@if (record.Status == DeploymentStatus.InProgress)
{
<span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"></span>
<span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"
role="status" aria-label="Deployment in progress"></span>
}
</span>
</td>
@@ -112,12 +129,33 @@
<span class="text-muted">—</span>
}
</td>
<td class="small"><code>@(record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)])</code></td>
<td class="small text-danger">@(record.ErrorMessage ?? "")</td>
<td class="small text-end">
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage))
{
<button class="btn btn-link btn-sm p-0" type="button"
@onclick="() => ToggleErrorExpansion(record.DeploymentId)"
aria-expanded="@(IsErrorExpanded(record.DeploymentId) ? "true" : "false")"
aria-controls="@errorCollapseId">
@(IsErrorExpanded(record.DeploymentId) ? "Hide error" : "View error")
</button>
}
</td>
</tr>
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage) && IsErrorExpanded(record.DeploymentId))
{
<tr id="@errorCollapseId" class="table-danger">
<td colspan="7">
<div class="small">
<strong>Error:</strong>
<pre class="mb-0 mt-1 small" style="white-space: pre-wrap; word-break: break-word;">@record.ErrorMessage</pre>
</div>
</td>
</tr>
}
}
</tbody>
</table>
}
@if (_totalPages > 1)
{
@@ -149,22 +187,56 @@
private bool _loading = true;
private string? _errorMessage;
private Timer? _refreshTimer;
private bool _autoRefresh = true;
private readonly HashSet<string> _expandedErrors = new();
private int _currentPage = 1;
private int _totalPages;
private const int PageSize = 25;
private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(10);
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
StartTimer();
}
private void StartTimer()
{
_refreshTimer?.Dispose();
_refreshTimer = new Timer(_ =>
{
InvokeAsync(async () =>
{
if (!_autoRefresh) return;
await LoadDataAsync();
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
}, null, RefreshInterval, RefreshInterval);
}
private void ToggleAutoRefresh()
{
_autoRefresh = !_autoRefresh;
if (_autoRefresh)
{
StartTimer();
}
else
{
_refreshTimer?.Dispose();
_refreshTimer = null;
}
}
private bool IsErrorExpanded(string deploymentId) => _expandedErrors.Contains(deploymentId);
private void ToggleErrorExpansion(string deploymentId)
{
if (!_expandedErrors.Remove(deploymentId))
{
_expandedErrors.Add(deploymentId);
}
}
private async Task LoadDataAsync()