fix(central-ui): resolve CentralUI-007..014 — nav authz, UTC date filters, disposal guards, N+1 fix, async script analysis

This commit is contained in:
Joseph Doherty
2026-05-16 20:58:03 -04:00
parent 738e67acc5
commit 71b90ba499
21 changed files with 976 additions and 81 deletions

View File

@@ -0,0 +1,41 @@
namespace ScadaLink.CentralUI.Components;
/// <summary>
/// Converts <c>&lt;input type="datetime-local"&gt;</c> values — which are always
/// expressed in the user's <i>browser-local</i> time zone — into UTC
/// <see cref="DateTimeOffset"/>s for querying.
/// <para>
/// CLAUDE.md mandates UTC throughout the system, but a <c>datetime-local</c>
/// value carries no offset, so it must be <i>converted</i> to UTC, not relabelled
/// as UTC. Relabelling (the CentralUI-008 bug) shifts every query window by the
/// user's offset for any non-UTC browser.
/// </para>
/// </summary>
public static class BrowserTime
{
/// <summary>
/// Converts a browser-local <paramref name="localValue"/> to UTC using the
/// browser's <c>Date.getTimezoneOffset()</c> result.
/// </summary>
/// <param name="localValue">
/// The wall-clock value from a <c>datetime-local</c> input, or <c>null</c>.
/// </param>
/// <param name="browserUtcOffsetMinutes">
/// The value of JavaScript <c>new Date().getTimezoneOffset()</c>: the number
/// of minutes that, <b>added</b> to local time, yields UTC. It is positive
/// for time zones behind UTC (e.g. +300 for UTC-5) and negative for zones
/// ahead (e.g. -120 for UTC+2).
/// </param>
/// <returns>The equivalent instant in UTC, or <c>null</c> when the input is null.</returns>
public static DateTimeOffset? LocalInputToUtc(DateTime? localValue, int browserUtcOffsetMinutes)
{
if (localValue is not { } local)
return null;
// getTimezoneOffset() is defined as (UTC - local) in minutes, so
// UTC = local + offset.
var utc = DateTime.SpecifyKind(local, DateTimeKind.Unspecified)
.AddMinutes(browserUtcOffsetMinutes);
return new DateTimeOffset(utc, TimeSpan.Zero);
}
}

View File

@@ -65,17 +65,22 @@
</Authorized>
</AuthorizeView>
@* Monitoring — visible to all authenticated users *@
@* Monitoring — Health Dashboard is all-roles; Event Logs and
Parked Messages are Deployment-role only (Component-CentralUI). *@
<div role="presentation" class="nav-section-header">Monitoring</div>
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
</li>
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
<Authorized Context="monitoringContext">
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
</li>
</Authorized>
</AuthorizeView>
@* Audit Log — Admin only *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">

View File

@@ -194,15 +194,12 @@
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_siteConnections.Clear();
foreach (var site in _sites)
{
var connections = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
if (connections.Count > 0)
{
_siteConnections[site.Id] = connections.ToList();
}
}
// CentralUI-012: fetch all data connections in one query and group
// them by site, instead of issuing one query per site (N+1).
_siteConnections = (await SiteRepository.GetAllDataConnectionsAsync())
.GroupBy(c => c.SiteId)
.ToDictionary(g => g.Key, g => g.ToList());
}
catch (Exception ex)
{

View File

@@ -293,6 +293,13 @@
private string? _initError;
// CentralUI-009: the stream callbacks (onEvent/onTerminated) run on an
// Akka/gRPC thread and capture `this` and `_toast`. Once the component is
// disposed, an in-flight callback must no-op rather than touch a disposed
// component (InvokeAsync would throw ObjectDisposedException) or a disposed
// ToastNotification.
private volatile bool _disposed;
protected override async Task OnInitializedAsync()
{
try
@@ -396,15 +403,18 @@
_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);
_ = InvokeAsync(StateHasChanged);
SafeInvokeStateHasChanged();
break;
case AlarmStateChanged al:
UpsertWithCap(_alarmStates, al.AlarmName, al);
_ = InvokeAsync(StateHasChanged);
SafeInvokeStateHasChanged();
break;
}
},
@@ -412,8 +422,11 @@
{
_connected = false;
_session = null;
_ = InvokeAsync(() =>
// CentralUI-009: skip the toast/render if already disposed.
if (_disposed) return;
_ = SafeInvokeAsync(() =>
{
if (_disposed) return;
_toast.ShowError("Debug stream terminated (site disconnected).");
StateHasChanged();
});
@@ -546,8 +559,31 @@
_ => "—"
};
/// <summary>
/// Runs <paramref name="action"/> on the render thread, guarded against the
/// component being disposed mid-flight (CentralUI-009): <c>InvokeAsync</c>
/// throws <see cref="ObjectDisposedException"/> once the circuit is gone.
/// </summary>
private async Task SafeInvokeAsync(Action action)
{
if (_disposed) return;
try
{
await InvokeAsync(action);
}
catch (ObjectDisposedException)
{
// Component disposed between the guard and the dispatch — ignore.
}
}
private void SafeInvokeStateHasChanged() => _ = SafeInvokeAsync(StateHasChanged);
public void Dispose()
{
// CentralUI-009: mark disposed first so any in-flight stream callback
// sees the flag and no-ops, then stop the stream synchronously.
_disposed = true;
if (_session != null)
{
DebugStreamService.StopStream(_session.SessionId);

View File

@@ -1,5 +1,6 @@
@page "/monitoring/audit-log"
@using ScadaLink.Security
@using ScadaLink.CentralUI.Components
@using ScadaLink.Commons.Entities.Audit
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@@ -195,6 +196,12 @@
private DateTime? _filterFrom;
private DateTime? _filterTo;
// The datetime-local filter inputs are in the browser's local time zone.
// This holds new Date().getTimezoneOffset() so the values are converted to
// UTC (CentralUI-008) rather than relabelled. Until JS interop runs it is 0
// (UTC), which is a safe default for a UTC server/browser.
private int _browserUtcOffsetMinutes;
private List<AuditLogEntry>? _entries;
private int _totalCount;
private int _page = 1;
@@ -209,6 +216,23 @@
private int TotalPages => _pageSize > 0 ? Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize) : 1;
private bool HasMore => _page * _pageSize < _totalCount;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
try
{
// Date.getTimezoneOffset() returns (UTC - local) in minutes.
_browserUtcOffsetMinutes = await JS.InvokeAsync<int>(
"eval", "new Date().getTimezoneOffset()");
}
catch (Exception ex) when (ex is JSException or JSDisconnectedException
or InvalidOperationException or TaskCanceledException)
{
// Prerender or a disconnected circuit: fall back to UTC (offset 0).
_browserUtcOffsetMinutes = 0;
}
}
private async Task Search()
{
_page = 1;
@@ -239,8 +263,8 @@
user: string.IsNullOrWhiteSpace(_filterUser) ? null : _filterUser.Trim(),
entityType: string.IsNullOrWhiteSpace(_filterEntityType) ? null : _filterEntityType.Trim(),
action: string.IsNullOrWhiteSpace(_filterAction) ? null : _filterAction.Trim(),
from: _filterFrom.HasValue ? new DateTimeOffset(_filterFrom.Value, TimeSpan.Zero) : null,
to: _filterTo.HasValue ? new DateTimeOffset(_filterTo.Value, TimeSpan.Zero) : null,
from: BrowserTime.LocalInputToUtc(_filterFrom, _browserUtcOffsetMinutes),
to: BrowserTime.LocalInputToUtc(_filterTo, _browserUtcOffsetMinutes),
page: _page,
pageSize: _pageSize);

View File

@@ -1,5 +1,5 @@
@page "/monitoring/event-logs"
@attribute [Authorize]
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.RemoteQuery

View File

@@ -1,5 +1,5 @@
@page "/monitoring/parked-messages"
@attribute [Authorize]
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.RemoteQuery

View File

@@ -150,6 +150,11 @@
public async ValueTask DisposeAsync()
{
// CentralUI-011: if the dialog is disposed while still open (the user
// navigated away), complete the pending task so the awaiting caller
// resumes deterministically instead of hanging forever.
_tcs?.TrySetResult(false);
if (_bodyLocked)
{
await TryUnlockBodyAsync();

View File

@@ -30,6 +30,16 @@
private readonly List<ToastItem> _toasts = new();
private readonly object _lock = new();
// Cancels all pending auto-dismiss delays when the component is disposed
// (CentralUI-010) so their continuations never touch a disposed component.
private readonly CancellationTokenSource _disposalCts = new();
/// <summary>Number of toasts currently displayed.</summary>
public int ToastCount
{
get { lock (_lock) { return _toasts.Count; } }
}
public void ShowSuccess(string message, string title = "Success", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Success, autoDismissMs);
@@ -52,6 +62,9 @@
private void AddToast(string title, string message, ToastType type, int? autoDismissMs)
{
// If the component is already disposed, do not add or schedule anything.
if (_disposalCts.IsCancellationRequested) return;
var toast = new ToastItem { Title = title, Message = message, Type = type };
lock (_lock)
{
@@ -60,14 +73,41 @@
StateHasChanged();
var dismissMs = autoDismissMs ?? DefaultAutoDismissMs;
_ = Task.Delay(dismissMs).ContinueWith(_ =>
_ = AutoDismissAsync(toast, dismissMs, _disposalCts.Token);
}
/// <summary>
/// Removes a toast after its dismiss delay. The delay is bound to the
/// component's disposal token (CentralUI-010): if the host page is disposed
/// first, the delay is cancelled and the continuation never touches the
/// disposed component — no <see cref="ObjectDisposedException"/> escapes.
/// </summary>
private async Task AutoDismissAsync(ToastItem toast, int dismissMs, CancellationToken token)
{
try
{
lock (_lock)
{
_toasts.Remove(toast);
}
InvokeAsync(StateHasChanged);
});
await Task.Delay(dismissMs, token);
}
catch (OperationCanceledException)
{
return;
}
if (token.IsCancellationRequested) return;
lock (_lock)
{
_toasts.Remove(toast);
}
try
{
await InvokeAsync(StateHasChanged);
}
catch (ObjectDisposedException)
{
// Component disposed between the token check and the render — ignore.
}
}
private void Dismiss(ToastItem toast)
@@ -87,7 +127,11 @@
_ => "bg-secondary text-white"
};
public void Dispose() { }
public void Dispose()
{
_disposalCts.Cancel();
_disposalCts.Dispose();
}
private enum ToastType { Success, Error, Warning, Info }

View File

@@ -19,11 +19,11 @@ public static class ScriptAnalysisEndpoints
group.MapPost("/completions", async (CompletionsRequest req, ScriptAnalysisService svc) =>
Results.Ok(await svc.CompleteAsync(req)));
group.MapPost("/hover", (HoverRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.Hover(req)));
group.MapPost("/hover", async (HoverRequest req, ScriptAnalysisService svc) =>
Results.Ok(await svc.Hover(req)));
group.MapPost("/signature-help", (SignatureHelpRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.SignatureHelp(req)));
group.MapPost("/signature-help", async (SignatureHelpRequest req, ScriptAnalysisService svc) =>
Results.Ok(await svc.SignatureHelp(req)));
group.MapPost("/format", (FormatRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.Format(req)));

View File

@@ -722,7 +722,7 @@ public class ScriptAnalysisService
public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
new(Array.Empty<InlayHint>());
public HoverResponse Hover(HoverRequest request)
public async Task<HoverResponse> Hover(HoverRequest request)
{
var script = TryParse(request.CodeText);
if (script == null) return new HoverResponse(null);
@@ -762,13 +762,13 @@ public class ScriptAnalysisService
var rawName = token.ValueText;
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
var shape = ResolveCalledShape(
var shape = await ResolveCalledShape(
call, rawName, request.SiblingScripts, request.Children, request.Parent);
if (shape == null) return new HoverResponse(null);
return new HoverResponse(FormatHover(shape, call));
}
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
public async Task<SignatureHelpResponse> SignatureHelp(SignatureHelpRequest request)
{
var empty = new SignatureHelpResponse(null, null, 0);
var script = TryParse(request.CodeText);
@@ -803,7 +803,7 @@ public class ScriptAnalysisService
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
var scriptName = nameArg?.Token.ValueText ?? "";
var shape = ResolveCalledShape(
var shape = await ResolveCalledShape(
call, scriptName, request.SiblingScripts, request.Children, request.Parent);
if (shape == null) return empty;
@@ -964,22 +964,35 @@ public class ScriptAnalysisService
_ => "script"
};
/// <summary>Resolves the called script's shape from the metadata in scope for its kind.</summary>
private ScriptShape? ResolveCalledShape(
/// <summary>
/// Resolves the called script's shape from the metadata in scope for its kind.
/// CentralUI-013: the shared-script catalog is awaited rather than blocked on
/// with <c>.GetAwaiter().GetResult()</c>, so this method is async — and
/// <see cref="Hover"/> / <see cref="SignatureHelp"/> are async with it.
/// </summary>
private async Task<ScriptShape?> ResolveCalledShape(
ScriptCallInfo call,
string scriptName,
IReadOnlyList<ScriptShape>? siblings,
IReadOnlyList<CompositionContext>? children,
CompositionContext? parent) => call.Kind switch
CompositionContext? parent)
{
ScriptCallKind.Shared => _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Sibling => siblings?.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Parent => parent?.Scripts.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Child => children?.FirstOrDefault(c => c.Name == call.CompositionName)
?.Scripts.FirstOrDefault(s => s.Name == scriptName),
_ => null
};
switch (call.Kind)
{
case ScriptCallKind.Shared:
var shapes = await _sharedScripts.GetShapesAsync();
return shapes.FirstOrDefault(s => s.Name == scriptName);
case ScriptCallKind.Sibling:
return siblings?.FirstOrDefault(s => s.Name == scriptName);
case ScriptCallKind.Parent:
return parent?.Scripts.FirstOrDefault(s => s.Name == scriptName);
case ScriptCallKind.Child:
return children?.FirstOrDefault(c => c.Name == call.CompositionName)
?.Scripts.FirstOrDefault(s => s.Name == scriptName);
default:
return null;
}
}
/// <summary>
/// SCADA006 — flag <c>Attributes["typo"]</c>,