Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor
T
Joseph Doherty 0f1f8ad8fa fix(adminui): lane D-b review r0 fixes (I1, M1, M2, M5-M9, N1-N3, N5, H3)
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
2026-10-22 18:21:46 -04:00

185 lines
7.5 KiB
Plaintext

@page "/scripts"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptList
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<PageErrorBoundary>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Scripts</h4>
<a href="/scripts/new" class="btn btn-primary btn-sm">New script</a>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count script@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No scripts defined.</div>
}
else
{
<table class="table table-sm mb-0 align-middle">
<thead>
<tr>
<th>Script</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var s in _rows)
{
<tr>
<td>
<div>
<span>@(string.IsNullOrWhiteSpace(s.Name) ? s.ScriptId : s.Name)</span>
<span class="chip chip-idle ms-1">@s.Language</span>
</div>
<div class="mono small text-muted">
@s.ScriptId · hash=@DisplayText.Abbreviate(s.SourceHash, 12)
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-sm btn-outline-secondary"
@onclick="() => ShowSource(s.ScriptId)">Source</button>
<a href="/scripts/@s.ScriptId" class="btn btn-sm btn-outline-primary">Edit</a>
</div>
</td>
</tr>
}
</tbody>
</table>
}
</section>
}
@* The dialog's EXISTENCE is gated on the held id (operator intent); the resolve drives only its
CONTENT. Gating existence on FindSourceScript() would make the modal a function of _rows, and any
render where the row is momentarily unresolvable (a future reload/poll setting _rows null or
replacing the list) would unmount the subtree and dispose the Close handler's binding id — a
click already in flight then throws GetRequiredEventBindingEntry. Latent today (_rows is written
once, in OnInitializedAsync) but the shape bit ScadaBridge live the moment a refresh landed. *@
@if (_sourceScriptId is not null)
{
var src = FindSourceScript();
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
@if (src is not null)
{
@(string.IsNullOrWhiteSpace(src.Name) ? src.ScriptId : src.Name)
<span class="mono small text-muted ms-2">@src.ScriptId</span>
}
else
{
<span class="mono">@_sourceScriptId</span>
}
</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseSource"></button>
</div>
<div class="modal-body">
@if (_sourceError is not null)
{
<div class="panel notice notice-alert mb-0">@_sourceError</div>
}
else if (src is null || (_sourceLoaded && _sourceCode is null))
{
<p class="text-muted mb-0">This script is no longer loaded.</p>
}
else if (!_sourceLoaded)
{
<p class="text-muted mb-0">Loading source…</p>
}
else
{
<pre class="mono small mb-0" style="background:var(--zebra-bg);padding:1rem;border-radius:4px;overflow:auto">@_sourceCode</pre>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseSource">Close</button>
</div>
</div>
</div>
</div>
}
</PageErrorBoundary>
@code {
// A projection without SourceCode (R04 P8); the source loads per dialog open.
private List<ScriptListRow>? _rows;
// Only the id of the script whose source dialog is open — never the record itself, so a reload
// that drops or replaces the row cannot leave a stale object on screen.
private string? _sourceScriptId;
// The open dialog's source, fetched on open. _sourceGeneration discards a superseded fetch (the
// operator opened another row, or closed the dialog, while the first one was in flight).
private string? _sourceCode;
private bool _sourceLoaded;
private string? _sourceError;
private int _sourceGeneration;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await ScriptListQueries.LoadRowsAsync(db);
}
/// <summary>Re-resolves the open dialog's script from the current list on every render. Drives
/// the dialog's CONTENT only — never its existence (see the comment at the markup): a script
/// that is no longer loaded renders a notice with the frame and Close still mounted.</summary>
private ScriptListRow? FindSourceScript()
=> _sourceScriptId is null
? null
: _rows?.FirstOrDefault(s => s.ScriptId == _sourceScriptId);
// One dialog at a time: opening another row replaces the id.
private async Task ShowSource(string scriptId)
{
var generation = ++_sourceGeneration;
_sourceScriptId = scriptId;
_sourceCode = null;
_sourceLoaded = false;
_sourceError = null;
string? source;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
source = await ScriptListQueries.LoadSourceAsync(db, scriptId);
}
catch (Exception ex)
{
// A ConfigDb hiccup must not escape the click handler into the page's error boundary (review r0 M7).
if (generation != _sourceGeneration) return;
_sourceError = $"The script source could not be loaded: {ex.Message}";
_sourceLoaded = true;
return;
}
if (generation != _sourceGeneration) return;
_sourceCode = source;
_sourceLoaded = true;
}
private void CloseSource()
{
_sourceGeneration++;
_sourceScriptId = null;
_sourceCode = null;
_sourceLoaded = false;
_sourceError = null;
}
}