fix(central-ui): resolve CentralUI-020..025 — auth-ping idle logout, DebugView race, push-handler disposal guard, JS-interop catch narrowing, claim-constant helper, SessionExpiry tests

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:16 -04:00
parent f82bcbed7c
commit d7d74ebe5e
28 changed files with 974 additions and 124 deletions

View File

@@ -160,11 +160,10 @@
</div>
@code {
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private List<Site> _sites = new();
private Dictionary<int, List<DataConnection>> _siteConnections = new();

View File

@@ -11,7 +11,8 @@
<AuthorizeView>
<Authorized>
<span class="text-muted small">
Signed in as <strong>@context.User.FindFirst("DisplayName")?.Value</strong>
@* CentralUI-024: claim type resolved via JwtTokenService. *@
Signed in as <strong>@context.User.GetDisplayName()</strong>
</span>
</Authorized>
</AuthorizeView>

View File

@@ -401,23 +401,7 @@
{
var session = await DebugStreamService.StartStreamAsync(
_selectedInstanceId,
onEvent: evt =>
{
// CentralUI-009: the component may have been disposed while
// this event was in flight on the Akka/gRPC thread.
if (_disposed) return;
switch (evt)
{
case AttributeValueChanged av:
UpsertWithCap(_attributeValues, av.AttributeName, av);
SafeInvokeStateHasChanged();
break;
case AlarmStateChanged al:
UpsertWithCap(_alarmStates, al.AlarmName, al);
SafeInvokeStateHasChanged();
break;
}
},
onEvent: HandleStreamEvent,
onTerminated: () =>
{
_connected = false;
@@ -503,10 +487,51 @@
_alarmStates.Clear();
}
/// <summary>
/// Handles one debug-stream event. The callback is invoked on an Akka/gRPC
/// thread, but <see cref="_attributeValues"/>/<see cref="_alarmStates"/> are
/// <see cref="Dictionary{TKey,TValue}"/> instances also enumerated by the
/// render thread via <see cref="FilteredAttributeValues"/>/
/// <see cref="FilteredAlarmStates"/>. <c>Dictionary</c> is not thread-safe
/// (CentralUI-021): a write racing an enumeration can throw or corrupt the
/// buckets. The mutation (<see cref="UpsertWithCap"/>) is therefore
/// marshalled onto the renderer's dispatcher via <see cref="SafeInvokeAsync"/>
/// so every access to the dictionaries — read and write — happens on the
/// render thread.
/// </summary>
private void HandleStreamEvent(object evt)
{
// CentralUI-009: the component may have been disposed while this event
// was in flight on the Akka/gRPC thread.
if (_disposed) return;
_ = SafeInvokeAsync(() =>
{
if (_disposed) return;
switch (evt)
{
case AttributeValueChanged av:
UpsertWithCap(_attributeValues, av.AttributeName, av);
break;
case AlarmStateChanged al:
UpsertWithCap(_alarmStates, al.AlarmName, al);
break;
default:
return;
}
StateHasChanged();
});
}
/// <summary>
/// Replace or insert a value keyed by name, then trim the oldest entries
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
/// preserves insertion order, so the first key is always the oldest.
/// <para>
/// Must be called on the render thread only (CentralUI-021) — see
/// <see cref="HandleStreamEvent"/>. The cap-trim loop is in the same
/// critical section as the upsert so the dictionary is never observed
/// over-capacity.
/// </para>
/// </summary>
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
{
@@ -577,8 +602,6 @@
}
}
private void SafeInvokeStateHasChanged() => _ = SafeInvokeAsync(StateHasChanged);
public void Dispose()
{
// CentralUI-009: mark disposed first so any in-flight stream callback

View File

@@ -204,6 +204,17 @@
private int _totalPages;
private const int PageSize = 25;
// CentralUI-022: IDeploymentStatusNotifier is a process singleton that
// raises StatusChanged on the DeploymentManager service thread. Dispose()
// unsubscribes, but the notifier can read its subscriber list and begin
// invoking OnDeploymentStatusChanged just before this component is disposed.
// The handler then runs against a disposed component and InvokeAsync throws
// ObjectDisposedException as an unobserved fire-and-forget task exception.
// This flag (set first in Dispose()) makes a racing callback no-op, and the
// dispatch swallows the residual ObjectDisposedException — mirroring the
// DebugView (CentralUI-009) and ToastNotification (CentralUI-010) guards.
private volatile bool _disposed;
// CentralUI-006: deployment status updates are push-based, not polled.
// DeploymentManager raises IDeploymentStatusNotifier.StatusChanged on every
// deployment-record status write; this page subscribes to it and reloads,
@@ -220,12 +231,34 @@
private void OnDeploymentStatusChanged(ScadaLink.DeploymentManager.DeploymentStatusChange change)
{
if (!_autoRefresh) return;
_ = InvokeAsync(async () =>
// CentralUI-022: a callback racing disposal must not touch the component.
if (_disposed || !_autoRefresh) return;
_ = DispatchReloadAsync();
}
/// <summary>
/// Reloads the deployment table on the renderer's dispatcher, guarded
/// against the component being disposed mid-flight (CentralUI-022):
/// <c>InvokeAsync</c> throws <see cref="ObjectDisposedException"/> once the
/// circuit is gone, and this handler runs fire-and-forget so that exception
/// would otherwise go unobserved on the DeploymentManager thread.
/// </summary>
private async Task DispatchReloadAsync()
{
if (_disposed) return;
try
{
await LoadDataAsync();
StateHasChanged();
});
await InvokeAsync(async () =>
{
if (_disposed) return;
await LoadDataAsync();
StateHasChanged();
});
}
catch (ObjectDisposedException)
{
// Component disposed between the guard and the dispatch — ignore.
}
}
private void ToggleAutoRefresh()
@@ -316,8 +349,10 @@
public void Dispose()
{
// Unsubscribe so a status change after the circuit is gone does not
// touch a disposed component (the notifier is a process singleton).
// CentralUI-022: set the guard first so a callback already in flight on
// the DeploymentManager thread no-ops, then unsubscribe so no further
// status change reaches this disposed component.
_disposed = true;
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
}
}

View File

@@ -438,11 +438,10 @@
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
// ── Bindings ────────────────────────────────────────────

View File

@@ -157,9 +157,8 @@
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
}

View File

@@ -921,9 +921,8 @@
}
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
}

View File

@@ -172,11 +172,10 @@
private ScriptAnalysis.SandboxRunResult? _runResult;
private CancellationTokenSource? _runCts;
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
protected override async Task OnInitializedAsync()
{

View File

@@ -101,11 +101,10 @@
</div>
@code {
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private List<SharedScript> _scripts = new();
private bool _loading = true;

View File

@@ -119,9 +119,8 @@
NavigationManager.NavigateTo("/design/templates");
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
}

View File

@@ -218,11 +218,10 @@
NavigationManager.NavigateTo("/design/templates");
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private RenderFragment RenderTemplateDetail() => __builder =>
{

View File

@@ -99,11 +99,10 @@
</div>
@code {
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();

View File

@@ -10,6 +10,7 @@
@inject CommunicationService CommunicationService
@inject IJSRuntime JS
@inject IDialogService Dialog
@inject ILogger<ParkedMessages> Logger
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
@@ -694,7 +695,18 @@
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
_toast.ShowSuccess("Copied to clipboard.");
}
catch { _toast.ShowError("Copy failed."); }
catch (JSDisconnectedException)
{
// Circuit gone — the page is being torn down; nothing to surface.
// CentralUI-023: distinguished from a genuine interop failure.
}
catch (JSException ex)
{
// A real clipboard failure (e.g. permission denied) — surface it to
// the user and log it so it is not invisible in production.
Logger.LogWarning(ex, "Clipboard copy failed.");
_toast.ShowError("Copy failed.");
}
}
// ── Helpers ──