Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor
T
Joseph Doherty 01033d7aaf
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 2m18s
ci / java (push) Successful in 2m24s
ci / portable (push) Successful in 8m51s
fix(dashboard): admin-UI cleanup sweep
Family-wide admin-UI cleanup pass (scadaproj admin_ui_cleanup.md) applied to the
Blazor dashboard. Behaviour is unchanged throughout — no @onclick, disabled,
binding, auth gate, or arm->confirm flow was touched.

Uncontrolled error text is now truncated at the render site. Fault messages,
Galaxy load errors, and browse-tree load failures were rendered in full into
fixed-width table cells, where a long exception string blows out the column.
Each site gets DashboardDisplay.Abbreviate plus a title attribute carrying the
untruncated text, so nothing becomes unreachable. Abbreviate is length-checked
rather than a bare range slice: `value[..n]` on a shorter string throws and
takes the whole page render down with it. The two detail views whose entire
purpose is to show one fault in full — SessionDetailsPage and GalaxyPage's
Last Error — are deliberately left untruncated.

Two classes referenced from markup had no definition anywhere in the sheet.
.browse-stale-banner was inert; .tree-load-status was a real visual defect —
loading and failed-to-load rows sit among .tree-row siblings and carry the same
leading .tree-toggle-empty spacer, but that spacer only takes its width as a
flex item, so without a flex container those rows lost their indent.

Confirm/cancel pairs in ConfirmDialog and the API-key create form are now
btn-groups with role="group" and an aria-label, replacing margin-spaced loose
buttons.

Removes a paragraph on GalaxyPage naming internal RPCs (DiscoverHierarchy,
GetLastDeployTime) — implementation detail with no meaning to a dashboard
operator.

Verified in a real browser, not bUnit: full build clean, 879/879 tests, and a
live gate against a running dashboard with a genuine ~250-char SqlClient
exception as the erroring row. Results per check, including the checks that
could NOT be exercised without an x86 worker, are recorded in
docs/plans/2026-08-11-dashboard-ui-sweeps.md.

That plan doc also records a correction: this app is NOT Bootstrap-free. The
sweep brief said it was, citing the scadaproj index; libman.json pins
bootstrap 5.3.3 and App.razor:7 links it ahead of the theme. The stale claim had
already cost this app one skipped family sweep (scadaproj#2, the /admin/secrets
modal), so that modal was live-gated here too and passes.
2026-08-11 05:48:22 -04:00

156 lines
5.3 KiB
Plaintext

@page "/workers"
@inherits DashboardPageBase
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardSessionAdminService SessionAdminService
<PageTitle>Dashboard Workers</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading workers.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Workers</h1>
<div class="text-secondary">@Snapshot.Workers.Count worker rows</div>
</div>
</div>
@if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage))
{
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
@ResultMessage
</div>
}
@if (CanManage)
{
<ConfirmDialog IsOpen="@(PendingSessionId is not null)"
Title="Kill worker?"
Message="@($"Forcefully kill the worker for session {PendingSessionId}? This skips graceful shutdown.")"
ConfirmLabel="Kill"
ConfirmButtonClass="btn-danger"
IsBusy="IsBusy"
OnConfirm="ConfirmKillAsync"
OnCancel="CancelPending" />
}
<section class="dashboard-section">
@if (Snapshot.Workers.Count == 0)
{
<div class="empty-state">No worker processes are attached.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle dashboard-table">
<thead>
<tr>
<th scope="col">Process</th>
<th scope="col">State</th>
<th scope="col">Session</th>
<th scope="col">Heartbeat</th>
<th scope="col">Fault</th>
@if (CanManage)
{
<th scope="col">Actions</th>
}
</tr>
</thead>
<tbody>
@foreach (DashboardWorkerSummary worker in Snapshot.Workers)
{
<tr>
<td>@(worker.ProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
<td><StatusBadge Text="@worker.State.ToString()" /></td>
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
@* Full text stays reachable on the tooltip and on the session detail page. *@
<td title="@worker.LastFault">@DashboardDisplay.Abbreviate(worker.LastFault)</td>
@if (CanManage)
{
<td>
<button type="button" class="btn btn-sm btn-outline-danger"
disabled="@IsBusy"
@onclick="() => RequestKill(worker.SessionId)">
Kill
</button>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private bool CanManage { get; set; }
private bool IsBusy { get; set; }
private string? ResultMessage { get; set; }
private bool LastOperationSucceeded { get; set; }
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync().ConfigureAwait(false);
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
.ConfigureAwait(false);
CanManage = SessionAdminService.CanManage(authenticationState.User);
}
private string? PendingSessionId { get; set; }
private void RequestKill(string sessionId)
{
if (IsBusy)
{
return;
}
PendingSessionId = sessionId;
}
private void CancelPending()
{
if (!IsBusy)
{
PendingSessionId = null;
}
}
private async Task ConfirmKillAsync()
{
if (IsBusy || PendingSessionId is null)
{
return;
}
string sessionId = PendingSessionId;
IsBusy = true;
try
{
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
.ConfigureAwait(false);
CanManage = SessionAdminService.CanManage(authenticationState.User);
DashboardSessionAdminResult result = await SessionAdminService
.KillWorkerAsync(authenticationState.User, sessionId, CancellationToken.None)
.ConfigureAwait(false);
ResultMessage = result.Message;
LastOperationSucceeded = result.Succeeded;
}
finally
{
IsBusy = false;
PendingSessionId = null;
}
}
}