0f1f8ad8fa
I1: /hosts eviction keyed off the AUTHORED DriverInstances, so a driver deleted in /raw but not yet deployed - still running, still publishing - vanished every tick. The stores' sweep is now EvictUnconfiguredStale(set, olderThanUtc): an entry goes only when unconfigured AND its PublishedUtc is older than 60 s. Tests cover unconfigured+fresh kept (positive control), unconfigured+stale evicted, configured+stale kept, and the deploy hand-off. M1: Test Connect falls back to the driver's documented ProbeTimeoutSeconds default (Galaxy 30, OpcUaClient 15, ...) when the key is absent. M2: the budget never undercuts the config's root timeoutMs (+1 s). M5: RawBrowseModal compares the open generation captured before the await, so a close-then-reopen mid-open closes the stale session. M6: /alarms-historian keeps a 30 s re-poll on 'not registered' (a driver node may still be starting) instead of stopping for good. M7: /scripts source fetch failure shows in the dialog, not the error boundary. M8: the export modal maps only its preview rows; the endpoint writes via Results.Text (no second byte[] copy). M9: /hosts config queries are ordered, so SequenceEqual change detection holds. N1 tooltip, N2 CRLF-safe regex, N3 throttled Warning for subscriber faults, N5 equipment-import help text now describes RFC 4180 quoting. H3: MonacoEditor documents that themes are page-global. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
140 lines
6.0 KiB
Plaintext
140 lines
6.0 KiB
Plaintext
@page "/alarms-historian"
|
|
@* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the
|
|
HistorianAdapterActor.GetStatus query landed in F11. *@
|
|
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
|
@rendermode RenderMode.InteractiveServer
|
|
@using Akka.Actor
|
|
@using Akka.Hosting
|
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
|
|
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
|
|
@using ZB.MOM.WW.OtOpcUa.Runtime
|
|
@using ZB.MOM.WW.OtOpcUa.Runtime.Historian
|
|
@inject IRequiredActor<HistorianAdapterActorKey> HistorianActor
|
|
@implements IAsyncDisposable
|
|
|
|
<PageErrorBoundary>
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">Alarms historian sink</h4>
|
|
</div>
|
|
|
|
<div class="text-muted small mb-3">Polling every @((int)(_interval == TimeSpan.Zero ? PollSeconds : _interval.TotalSeconds)) s.</div>
|
|
|
|
@if (_reading is { Availability: HistorianSinkAvailability.NotOnThisRole })
|
|
{
|
|
<section class="panel notice rise mt-3" style="animation-delay:.04s">@_reading.Message Checking again every @((int)HistorianSinkStatusReader.NotRegisteredPollInterval.TotalSeconds) s in case the node is still starting.</section>
|
|
}
|
|
else if (_status is null && _reading is { Availability: HistorianSinkAvailability.Unavailable })
|
|
{
|
|
<section class="panel notice notice-alert rise mt-3" style="animation-delay:.04s">@_reading.Message Retrying every @PollSeconds s.</section>
|
|
}
|
|
else if (_status is null)
|
|
{
|
|
<p class="mt-3">Loading…</p>
|
|
}
|
|
else
|
|
{
|
|
@if (_reading is { Availability: HistorianSinkAvailability.Unavailable })
|
|
{
|
|
<section class="panel notice notice-alert rise mt-3" style="animation-delay:.04s">
|
|
@_reading.Message Showing the last status read at @_statusReadUtc?.ToString("u").
|
|
</section>
|
|
}
|
|
<section class="card-grid rise mt-3" style="animation-delay:.08s">
|
|
<div class="metric-card">
|
|
<div class="panel-head">Queue</div>
|
|
<div class="kv"><span class="k">Depth</span><span class="v numeric">@_status.QueueDepth</span></div>
|
|
<div class="kv"><span class="k">Dead-lettered</span><span class="v numeric">@_status.DeadLetterDepth</span></div>
|
|
<div class="kv"><span class="k">Evicted (lifetime)</span><span class="v numeric">@_status.EvictedCount</span></div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="panel-head">Drain state</div>
|
|
<div class="kv"><span class="k">State</span><span class="v"><span class="@StateChipClass(_status.DrainState)">@_status.DrainState</span></span></div>
|
|
<div class="kv"><span class="k">Last drain</span><span class="v">@(_status.LastDrainUtc?.ToString("u") ?? "—")</span></div>
|
|
<div class="kv"><span class="k">Last success</span><span class="v">@(_status.LastSuccessUtc?.ToString("u") ?? "—")</span></div>
|
|
@if (!string.IsNullOrWhiteSpace(_status.LastError))
|
|
{
|
|
<div class="kv"><span class="k">Last error</span><span class="v text-danger small" title="@_status.LastError">@DisplayText.Abbreviate(_status.LastError, 80)</span></div>
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
</PageErrorBoundary>
|
|
|
|
@code {
|
|
private const int PollSeconds = 5;
|
|
|
|
// The Fleet.razor lifecycle (R04 L3): IAsyncDisposable, a _disposed flag, an overlap guard and a
|
|
// page-lifetime CTS, so a queued tick after teardown neither Asks nor renders.
|
|
private HistorianSinkStatus? _status;
|
|
private DateTime? _statusReadUtc;
|
|
private HistorianSinkReading? _reading;
|
|
private Timer? _timer;
|
|
private int _tickRunning;
|
|
private TimeSpan _interval;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private volatile bool _disposed;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await RefreshAsync();
|
|
// "Not registered" is usually permanent (an admin-only node) but can also be a driver node still
|
|
// starting up, so it keeps a slow re-poll instead of stopping for good (review r0 M6).
|
|
_interval = HistorianSinkStatusReader.PollIntervalFor(_reading?.Availability);
|
|
_timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), null, _interval, _interval);
|
|
}
|
|
|
|
private async Task RefreshAsync()
|
|
{
|
|
if (_disposed || Interlocked.Exchange(ref _tickRunning, 1) == 1) return;
|
|
try
|
|
{
|
|
_reading = await HistorianSinkStatusReader.ReadAsync(
|
|
() => HistorianActor.ActorRef, HistorianSinkStatusReader.AskTimeout, _cts.Token);
|
|
if (_reading.Status is not null)
|
|
{
|
|
_status = _reading.Status;
|
|
_statusReadUtc = DateTime.UtcNow;
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
|
|
{
|
|
return; // the page went away mid-Ask
|
|
}
|
|
finally
|
|
{
|
|
Volatile.Write(ref _tickRunning, 0);
|
|
}
|
|
|
|
if (_disposed) return;
|
|
var interval = HistorianSinkStatusReader.PollIntervalFor(_reading?.Availability);
|
|
if (_timer is not null && interval != _interval)
|
|
{
|
|
_interval = interval;
|
|
try { _timer.Change(interval, interval); }
|
|
catch (ObjectDisposedException) { return; }
|
|
}
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
private static string StateChipClass(HistorianDrainState state) => state switch
|
|
{
|
|
HistorianDrainState.Disabled => "chip chip-idle",
|
|
HistorianDrainState.Idle => "chip chip-idle",
|
|
HistorianDrainState.Draining => "chip chip-ok",
|
|
HistorianDrainState.BackingOff => "chip chip-caution",
|
|
_ => "chip chip-idle",
|
|
};
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_disposed = true;
|
|
_cts.Cancel();
|
|
// Drains an in-flight callback (System.Threading.Timer async dispose, .NET 6+). The callback only
|
|
// schedules the tick, so a tick may still hold _cts — skip the dispose then (see Hosts.razor).
|
|
if (_timer is not null) await _timer.DisposeAsync();
|
|
if (Volatile.Read(ref _tickRunning) == 0) _cts.Dispose();
|
|
}
|
|
}
|