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
249 lines
11 KiB
Plaintext
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}}/<RefName></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 > 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();
|
|
}
|
|
}
|