@page "/scripts/new" @page "/scripts/{ScriptId}" @* Script CRUD. SourceHash is computed automatically from SourceCode on save so the integrity check in v2's deployment pipeline doesn't require operator action. *@ @attribute [Microsoft.AspNetCore.Authorization.Authorize] @rendermode RenderMode.InteractiveServer @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore @using System.ComponentModel.DataAnnotations @using System.Security.Cryptography @using System.Text @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject IDbContextFactory DbFactory @inject NavigationManager Nav @inject IJSRuntime JS

@(IsNew ? "New script" : "Edit script")

Cancel
@if (!_loaded) {

Loading…

} else if (!IsNew && _existing is null) {
@ScriptId not found.
} else {
Identity
Source
@* The textarea stays in the DOM and remains Blazor's source of truth. Monaco mounts a
beside it (textarea hides), and the loader's onDidChangeModelContent handler mirrors edits back into the textarea + fires the input event so @bind picks them up. Falls back to the textarea gracefully if Monaco's CDN is unreachable (air-gapped deployments — see monaco-loader.js). *@
SHA-256 hash is computed automatically on save. Monaco editor attaches over the textarea on render.
@if (!string.IsNullOrWhiteSpace(_error)) {
@_error
}
Cancel @if (!IsNew) { }
} @code { [Parameter] public string? ScriptId { get; set; } private bool IsNew => string.IsNullOrEmpty(ScriptId); private FormModel _form = new(); private Script? _existing; private bool _loaded; private bool _busy; private string? _error; protected override async Task OnInitializedAsync() { if (!IsNew) { await using var db = await DbFactory.CreateDbContextAsync(); _existing = await db.Scripts.AsNoTracking().FirstOrDefaultAsync(s => s.ScriptId == ScriptId); if (_existing is not null) { _form = new FormModel { ScriptId = _existing.ScriptId, Name = _existing.Name, Language = _existing.Language, SourceCode = _existing.SourceCode, RowVersion = _existing.RowVersion, }; } } _loaded = true; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender || !_loaded) return; // Inject loader once, then attach over the textarea. Failures are silent — the page // is fully usable via the underlying textarea if Monaco's CDN is unreachable. try { await JS.InvokeVoidAsync("eval", "if (!document.querySelector('script[data-otopcua=monaco-loader]')) { var s=document.createElement('script'); s.src='/_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-loader.js'; s.dataset.otopcua='monaco-loader'; document.head.appendChild(s); }"); // Wait a tick for the loader IIFE to register window.otOpcUaScriptEditor, then attach. await Task.Delay(50); await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", "script-source"); } catch { // Textarea remains the editor — no-op. } } private async Task SubmitAsync() { _busy = true; _error = null; try { var sourceHash = HashSource(_form.SourceCode); await using var db = await DbFactory.CreateDbContextAsync(); if (IsNew) { if (await db.Scripts.AnyAsync(s => s.ScriptId == _form.ScriptId)) { _error = $"Script '{_form.ScriptId}' already exists."; return; } db.Scripts.Add(new Script { ScriptId = _form.ScriptId, Name = _form.Name, Language = _form.Language, SourceCode = _form.SourceCode, SourceHash = sourceHash, }); } else { var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == ScriptId); if (entity is null) { _error = "Row no longer exists."; return; } db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; entity.Name = _form.Name; entity.Language = _form.Language; entity.SourceCode = _form.SourceCode; entity.SourceHash = sourceHash; } await db.SaveChangesAsync(); Nav.NavigateTo("/scripts"); } catch (DbUpdateConcurrencyException) { _error = "Another user changed this script while you were editing."; } catch (Exception ex) { _error = ex.Message; } finally { _busy = false; } } private async Task DeleteAsync() { if (IsNew) return; _busy = true; _error = null; try { await using var db = await DbFactory.CreateDbContextAsync(); var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == ScriptId); if (entity is null) { Nav.NavigateTo("/scripts"); return; } db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; db.Scripts.Remove(entity); await db.SaveChangesAsync(); Nav.NavigateTo("/scripts"); } catch (DbUpdateConcurrencyException) { _error = "Another user changed this script while you were viewing it."; } catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because virtual tags or scripted alarms still reference this script — remove them first."; } finally { _busy = false; } } private static string HashSource(string source) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source))); private sealed class FormModel { [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string ScriptId { get; set; } = ""; [Required] public string Name { get; set; } = ""; [Required] public string Language { get; set; } = "CSharp"; [Required] public string SourceCode { get; set; } = ""; public byte[] RowVersion { get; set; } = []; } }