Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.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

249 lines
11 KiB
Plaintext

@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
@using Microsoft.Extensions.Logging
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
@if (ShowToolbar)
{
<div class="d-flex justify-content-end align-items-center gap-3 mb-1 small text-muted">
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="FormatAsync"
title="Format document (Ctrl/Cmd+Shift+F)">Format</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleWrap"
title="Word wrap">@(_wrap ? "Wrap on" : "Wrap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleMinimap"
title="Toggle minimap">@(_minimap ? "Minimap on" : "Minimap off")</button>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleTheme"
title="Toggle theme">@(_dark ? "Dark" : "Light")</button>
</div>
}
<div @ref="_hostRef" class="monaco-editor-host"
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
@if (_tooLargeBytes > 0)
{
@* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@
<div class="text-warning small mt-1" role="status">
This script is too large to send to the server (@_tooLargeBytes bytes; the limit is
@ScriptEditorLimits.MaxEditorSyncBytes bytes). It cannot be saved until it is shortened.
</div>
}
@code {
/// <summary>
/// The editor text. Two-way bound, but the editor reports edits back only when it loses focus — never per
/// keystroke (each report is one circuit message carrying the whole script). A page that persists the text
/// must read the authoritative current value with <see cref="GetValueAsync"/> at save time.
/// </summary>
[Parameter] public string Value { get; set; } = "";
/// <summary>Raised with the editor text when the editor loses focus after an edit (and from <see cref="GetValueAsync"/>).</summary>
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Language { get; set; } = "csharp";
[Parameter] public string Height { get; set; } = "320px";
[Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Owning-equipment context for equipment-relative <c>{{equip}}/&lt;RefName&gt;</c> completion +
/// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared
/// ScriptEdit page (where the token cannot resolve to a single equipment).
/// </summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>
/// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response
/// to the CURRENT text — superseded responses are dropped in the JS). Feeds <c>ScriptProblemsList</c>; the
/// markers are sent from JS only when this callback has a subscriber, and at most
/// <see cref="ScriptEditorLimits.MaxReportedMarkers"/> of them (plus the total) so the report always fits in one
/// circuit message.
/// </summary>
[Parameter] public EventCallback<ScriptProblemReport> MarkersChanged { get; set; }
private ElementReference _hostRef;
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
private readonly string _id = Guid.NewGuid().ToString("N");
private string _lastSentValue = "";
private bool _initialized;
private int _tooLargeBytes;
private bool _wrap;
private bool _minimap;
private bool _dark;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_dotNetRef = DotNetObjectReference.Create(this);
_lastSentValue = Value ?? "";
try
{
await JS.InvokeVoidAsync(
"MonacoBlazor.createEditor",
_id,
_hostRef,
new
{
value = Value ?? "",
language = Language,
readOnly = ReadOnly,
equipmentId = EquipmentId,
maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes,
reportMarkers = MarkersChanged.HasDelegate,
maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers,
maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars
},
_dotNetRef);
_initialized = true;
}
catch (InvalidOperationException)
{
// Prerendering: JS interop is not available yet — the next
// (interactive) render retries. Expected, not logged.
}
catch (JSDisconnectedException)
{
// Circuit disconnected before init completed — nothing to do.
}
catch (JSException ex)
{
// A genuine Monaco init failure — surface it instead of hiding it.
Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id);
}
}
else if (_initialized && (Value ?? "") != _lastSentValue)
{
_lastSentValue = Value ?? "";
await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue);
}
}
/// <summary>
/// Invokes a Monaco JS function, swallowing the expected disconnect case but
/// logging any genuine JS error so failures are not silent.
/// </summary>
private async ValueTask SafeInvokeAsync(string fn, string action, params object?[] args)
{
try
{
await JS.InvokeVoidAsync(fn, args);
}
catch (JSDisconnectedException)
{
// Circuit gone — the editor no longer exists; nothing to log.
}
catch (JSException ex)
{
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to {Action}.", _id, action);
}
}
[JSInvokable]
public Task OnValueChanged(string newValue)
{
var normalized = newValue ?? "";
if (normalized == _lastSentValue)
return Task.CompletedTask;
_lastSentValue = normalized;
return ValueChanged.InvokeAsync(_lastSentValue);
}
/// <summary>
/// Reads the editor's current text on demand (call it at Save), raises <see cref="ValueChanged"/> when it
/// differs from the last value reported, and returns it. Before the editor is live (prerender, failed init)
/// the bound <see cref="Value"/> is authoritative and is returned as-is.
/// </summary>
/// <returns>The current text, or an error (e.g. the script is too large to send) — never a stale value.</returns>
public async Task<EditorReadResult> GetValueAsync()
{
if (!_initialized) return new EditorReadResult(Value ?? "", null);
string? current;
try
{
current = await JS.InvokeAsync<string?>("MonacoBlazor.getValue", _id);
}
catch (JSDisconnectedException)
{
return new EditorReadResult(null, "The connection to the editor was lost; reload the page and try again.");
}
catch (JSException ex)
{
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to read the editor text.", _id);
// JSException.Message carries the JS stack after the first line; the author needs only the reason.
return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim());
}
if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed
_tooLargeBytes = 0; // it was read, so it fits
await OnValueChanged(current);
return new EditorReadResult(current, null);
}
/// <summary>The outcome of <see cref="GetValueAsync"/>: the text, or why it could not be read.</summary>
/// <param name="Value">The editor text; null when <paramref name="Error"/> is set.</param>
/// <param name="Error">Why the text could not be read; null on success.</param>
public sealed record EditorReadResult(string? Value, string? Error)
{
/// <summary>True when <see cref="Value"/> holds the editor text.</summary>
public bool Ok => Error is null;
}
[JSInvokable]
public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) =>
MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total));
/// <summary>The JS reports, at blur, that the text has grown past (bytes &gt; 0) or back under (0) the sync limit.</summary>
[JSInvokable]
public void OnSyncRefused(int bytes)
{
if (_tooLargeBytes == bytes) return;
_tooLargeBytes = bytes;
StateHasChanged();
}
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
public async Task RevealLineAsync(int line, int column = 1)
{
if (!_initialized) return;
await SafeInvokeAsync("MonacoBlazor.revealLine", "reveal line", _id, line, column);
}
private async Task FormatAsync()
{
if (!_initialized) return;
await SafeInvokeAsync("MonacoBlazor.format", "format document", _id);
}
private async Task ToggleWrap()
{
_wrap = !_wrap;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle word wrap", _id, "wordWrap", _wrap ? "on" : "off");
}
private async Task ToggleMinimap()
{
_minimap = !_minimap;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap });
}
// R04 H3 (accepted, cosmetic): Monaco themes are page-global — the JS side calls
// monaco.editor.setTheme, which re-themes EVERY editor on the page (e.g. a VirtualTagModal open over
// ScriptEdit), not just this one, and the other instance's _dark flag then no longer matches what it shows.
// Monaco has no per-editor theme; a fix would need a page-level theme service shared by all instances.
private async Task ToggleTheme()
{
_dark = !_dark;
await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle theme", _id, "theme", _dark ? "vs-dark" : "vs");
}
public async ValueTask DisposeAsync()
{
if (_initialized)
{
// Disposal commonly races a circuit disconnect — JSDisconnectedException
// here is expected and silent; a real JSException is still logged.
await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id);
}
_dotNetRef?.Dispose();
}
}