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:
41
src/ScadaLink.CentralUI/Components/BrowserTime.cs
Normal file
41
src/ScadaLink.CentralUI/Components/BrowserTime.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace ScadaLink.CentralUI.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Converts <c><input type="datetime-local"></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);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user