Full IntelliSense parity with scadabridge (completions, hover, signature help, live diagnostics, formatting, inlay hints, global tag-path completion), re-seated on OtOpcUa's real script compile context (ScriptSandbox + VirtualTagContext wrapper + ForbiddenTypeAnalyzer + DependencyExtractor). Reusable MonacoEditor.razor wired into the ScriptEdit page and the virtual-tag modal; Monaco vendored locally.
16 KiB
Monaco Script Editor (Roslyn-backed IntelliSense) — Design
Date: 2026-06-09
Status: Approved (brainstorming complete; ready for implementation plan)
Author: brainstorming session
Related: docs/Uns.md, src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/, sister project scadabridge (/Users/dohertj2/Desktop/scadabridge/src)
Goal
Replace OtOpcUa's proof-of-concept script editor (a CDN Monaco instance mounted
over a hidden textarea via an eval + Task.Delay(50) race) with a proper,
reusable Monaco code editor offering full Roslyn-backed IntelliSense — the
same class of experience as the sister project scadabridge: completions,
hover, signature help, live diagnostics, document formatting, inlay hints, and
context-aware tag-path completion — but re-seated on OtOpcUa's actual script
compile context so what the editor accepts/rejects exactly matches what publish
enforces.
Background — current state
| Aspect | scadabridge (reference) | OtOpcUa (today) |
|---|---|---|
| Monaco delivery | Vendored locally (wwwroot/lib/monaco/vs/, served from _content/), v0.42 |
CDN (jsDelivr monaco-editor@0.52.2), injected at runtime |
| Editor component | Reusable MonacoEditor.razor (Value/ValueChanged, theme, toolbar, DotNetObjectReference) |
None — eval-injected loader over a hidden <InputTextArea>, sync-back via dispatched input events |
| IntelliSense | Full: completions/hover/signature-help/diagnostics/format/inlay via /api/script-analysis/* → ScriptAnalysisService (Roslyn) |
None — plaintext csharp highlighting only |
| Load robustness | Deterministic | Fragile 50 ms delay; silent fallback to textarea |
| Air-gap | Safe (vendored) | Falls back to a plain textarea when CDN unreachable |
The current code self-documents the gap: comments say richer editing "lands in Phase D.2."
OtOpcUa scripting runtime (the context IntelliSense must reproduce)
-
Evaluator:
ScriptEvaluator(src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs) builds a hand-rolledCSharpCompilation(NOTCSharpScript). It wraps the user's source in:using System; using System.Linq; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Scripting; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled; public static class CompiledScript { public static TResult Run(ScriptGlobals<TContext> globals) { var ctx = globals.ctx; #line 1 «user source» // statement body, MUST end with an explicit `return` } } -
Globals / context:
ScriptGlobals<VirtualTagContext>exposesctxof typeVirtualTagContext : ScriptContext. The in-scope API a script author can call:ctx.GetTag(string path)→DataValueSnapshot(.Value,.StatusCode,.SourceTimestampUtc,.ServerTimestampUtc)ctx.SetVirtualTag(string path, object? value)ctx.Now→DateTimectx.Logger→Serilog.ILoggerScriptContext.Deadband(double current, double previous, double tolerance)(static)
These are real, referenceable types (
Scripting.Abstractions), so Roslyn analysis can point at the genuine types — no mirror "host" classes needed (scadabridge had to build mirrors because its runtime globals weren't directly referenceable). -
Sandbox:
ScriptSandbox.Build(typeof(VirtualTagContext))(src/Core/Scripting/ScriptSandbox.cs) supplies the imports (above) and the restricted metadata-reference set (pinned OtOpcUa assemblies + a BCL subset; noSystem.IO/System.Net/Process/Reflection). -
Hard gates publish enforces:
ForbiddenTypeAnalyzer— semantic gate against type-forwarding sandbox escapes (e.g. resolvingHttpClient).DependencyExtractor.Extract(source)— AST walk requiringctx.GetTag/ctx.SetVirtualTagpaths to be string literals; rejects variables, concatenation, interpolation. Also yields the read/write dependency sets.
-
Binding:
VirtualTag.ScriptId→Script.ScriptId(logical FK). AScriptis global — one script may be referenced by multiple virtual tags across different equipment, and those tags may have differentDataTypes. The script is edited as aScriptrow (SourceCode+ SHA-256SourceHash), not per tag.
Decisions (from brainstorming)
- Scope: Full IntelliSense parity with scadabridge (completions, hover, signature help, diagnostics, formatting, inlay hints) — Roslyn-backed.
- Location: Reusable
MonacoEditor.razor, wired into both the ScriptEdit page (/scripts/{id}) and the Edit-Virtual-Tag modal (inline script-source panel). - Tag-path completion: Yes, in v1 — global completion of all configured
tag + virtual-tag paths inside
ctx.GetTag("…")/ctx.SetVirtualTag("…")string literals. - Vendoring: Monaco vendored locally (air-gap safe), replacing the CDN.
- Modal save-model: Inline script editing in the modal is its own
RowVersion-guarded Save, separate from the virtual-tag Create/Save, with a "Used by N virtual tags — edits affect all of them" warning.
Architecture
┌──────────────────────────── AdminUI (Blazor Server) ────────────────────────────┐
│ │
│ Components/Shared/MonacoEditor.razor ◄── Value/ValueChanged, theme, toolbar │
│ │ (DotNetObjectReference both ways) │
│ ▼ │
│ wwwroot/js/monaco-init.js ──registers──► completion / hover / signatureHelp / │
│ │ diagnostics / format / inlayHints │
│ │ fetch POST /api/script-analysis/* │
│ ▼ │
│ ScriptAnalysis/ScriptAnalysisEndpoints.cs (minimal-API group, authz-gated) │
│ ▼ │
│ ScriptAnalysis/ScriptAnalysisService.cs ── Roslyn over OtOpcUa's REAL wrapper │
│ │ ├─ ScriptSandbox.Build(typeof(VirtualTagContext)) (imports + refs) │
│ │ ├─ CompiledScript.Run(ScriptGlobals<VirtualTagContext>) wrapper │
│ │ ├─ ForbiddenTypeAnalyzer (sandbox-escape squiggles) │
│ │ ├─ DependencyExtractor (dynamic-path squiggles) │
│ │ └─ IScriptTagCatalog (tag-path string-literal completions) │
│ ▼ │
│ wwwroot/lib/monaco/vs/… (vendored Monaco, one <script> in App.razor) │
└──────────────────────────────────────────────────────────────────────────────────┘
│ references
▼
Core.Scripting + Scripting.Abstractions (ScriptSandbox, VirtualTagContext,
ScriptGlobals<>, ForbiddenTypeAnalyzer, DependencyExtractor)
Configuration (OtOpcUaConfigDbContext → tag/virtual-tag paths for the catalog)
Component placement
ScriptAnalysis/ lives inside ZB.MOM.WW.OtOpcUa.AdminUI (mirrors
scadabridge's CentralUI layout). New build dependencies on the AdminUI project:
- Project refs:
Core.Scripting,Scripting.Abstractions(and transitivelyCore.Abstractions). The AdminUI already referencesConfiguration. - Packages:
Microsoft.CodeAnalysis.CSharp.ScriptingandMicrosoft.CodeAnalysis.CSharp.Workspaces(pin to scadabridge's 5.0.0 on net10; confirm at plan time).
ScriptAnalysisService — the analysis core
Ported from scadabridge (~90% reusable: position mapping, completion/hover/ signature-help/format/inlay extraction, semantic-model access) but re-seated on OtOpcUa's real compile context:
- Wrapped document. Build the analysis source = OtOpcUa's evaluator wrapper
with the user's text spliced after
#line 1, return typeobject(a shared script must stay valid no matter which virtual tag'sDataTypeconsumes it; per-tag return coercion remains a runtime concern). UseScriptSandbox.Build(typeof(VirtualTagContext))for imports + references — single source of truth shared with the evaluator. - Compilation + semantic model.
CSharpCompilation.Createover the wrappedSyntaxTree;compilation.GetSemanticModel(tree). - Cursor mapping. Editor
(line, column)→ offset within the user source → add the wrapper-prefix length → physical offset in the wrapped document. The#line 1directive makes Roslyn report diagnostic line numbers already in user-source coordinates; completions/hover/signature use the physical offset. - Completions.
semanticModel.LookupSymbols(position)for scope; member completion after.; string-literal completion insidectx.GetTag("…")/ctx.SetVirtualTag("…")delegates toIScriptTagCatalog(replaces scadabridge'sParameters["…"]/Attributes["…"]/Children["…"]cases). - Diagnostics (debounced ~500 ms client-side) = union of:
- Roslyn compile diagnostics (errors + warnings) on the wrapped compilation.
ForbiddenTypeAnalyzerviolations → error markers.DependencyExtractordynamic-path rejections → error markers with the offending span. (These three are precisely what publish enforces.)
- Hover / signature help / format / inlay hints. Ported near-verbatim from scadabridge's Roslyn logic; the scadabridge domain-specific string-literal branches are replaced/removed.
Registered scoped (the catalog is scoped over the DB context); metadata
references from ScriptSandbox are static and cached. A small memory cache
(per scadabridge) backs repeated analysis of identical source.
IScriptTagCatalog
New scoped service. GetPathsAsync(string? filter, CancellationToken) → distinct
configured tag + virtual-tag paths from OtOpcUaConfigDbContext, used for
string-literal completion. Global (scripts are not equipment-scoped). The
endpoint passes the current literal prefix as filter to bound result size.
HTTP endpoints
ScriptAnalysis/ScriptAnalysisEndpoints.cs — MapScriptAnalysisEndpoints()
maps a /api/script-analysis group, gated by the AdminUI's design/write
authorization policy:
| Route | Request | Purpose |
|---|---|---|
POST /diagnostics |
DiagnoseRequest |
Roslyn + ForbiddenType + dynamic-path markers |
POST /completions |
CompletionsRequest |
scope/member + tag-path completion |
POST /hover |
HoverRequest |
type/quick-info markdown |
POST /signature-help |
SignatureHelpRequest |
parameter help |
POST /format |
FormatRequest |
NormalizeWhitespace() formatting |
POST /inlay-hints |
InlayHintsRequest |
inline type hints |
DTOs ported from scadabridge's ScriptAnalysisContracts.cs minus the
ScriptKind enum (OtOpcUa has a single script kind: virtual-tag).
Front-end
Components/Shared/MonacoEditor.razor— reusable:[Parameter] Value,ValueChanged,Height,ReadOnly,ShowToolbar, theme toggle (vs/vs-dark), Format button. Creates the editor inOnAfterRenderAsync, two-way binding viaDotNetObjectReference(OnValueChanged→ValueChanged), externalValuechanges pushed viasetValue,disposeon teardown, GUID-keyed for multi-instance safety.wwwroot/js/monaco-init.js— ported loader +window.MonacoBlazorwithcreateEditor/setValue/getValue/setMarkers/setEditorOption/format/dispose, and the six Monaco language providers POSTing to/api/script-analysis/*(diagnostics debounced).- Vendored Monaco under
wwwroot/lib/monaco/vs/…, pulled in by a single<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js">inApp.razor. The CDNmonaco-loader.js+ theeval/Task.Delayinjection inScriptEdit.razorare removed.
Wire-in points
- ScriptEdit page (
/scripts/{id}): replace<InputTextArea>with<MonacoEditor @bind-Value="_form.SourceCode" Height="…">. Save path unchanged (SHA-256 on save). The oldOnAfterRenderAsyncJS-injection block is deleted. - VirtualTagModal: below the Script dropdown, a collapsible "Script source"
panel that loads the selected script's
SourceCodeinto aMonacoEditor, shows a "Used by N virtual tags — edits affect all of them" notice (count from the config DB), and a dedicated Save script button performing aRowVersion-guarded update of theScriptrow — a separate concurrency unit from the virtual-tag Create/Save. Creating a brand-new script inline is a follow-up, not v1.
Data flow (a keystroke)
- User types in Monaco →
onDidChangeModelContent→ValueChangedupdates the Blazor model; debounce schedules a diagnostics fetch. - Provider POSTs
{ source, line, column }to/api/script-analysis/*. ScriptAnalysisServicewraps the source, compiles, computes the result at the mapped offset (consultingIScriptTagCatalogfor path literals), returns DTOs.- JS provider maps DTOs to Monaco completions/markers/hover and renders them.
Error handling
- CDN/JS load failure: with vendoring there is no network dependency; if the vendored asset is somehow missing, the editor surfaces a clear init error rather than silently degrading. (No more silent textarea fallback masking a broken editor.)
- Analysis exceptions: each endpoint is defensive — a malformed request or an internal Roslyn failure returns an empty result (no completions/markers) rather than 500-ing the editor; logged server-side.
- Concurrency: modal script Save uses
RowVersion; a conflict surfaces the standard "changed by someone else, reload" message (existing pattern). - Auth: endpoints require the AdminUI design/write policy; read-only users get the editor read-only.
Testing
No bUnit (established AdminUI pattern — razor/JS proven only by live /run).
ScriptAnalysisService(unit, xUnit + Shouldly): member completion afterctx.; scope completion; tag-path completion insideGetTag/SetVirtualTag(via a fakeIScriptTagCatalog); hover onctx.GetTag; signature help; format round-trip; diagnostics overlays — a clean script (no markers), a Roslyn error, aForbiddenTypeAnalyzerviolation (e.g.HttpClient), and a dynamic-path rejection (ctx.GetTag(someVar)); cursor-offset/#linemapping (marker lands on the right user-source line).IScriptTagCatalog(unit, in-memory EF — Configuration.Tests pattern): returns distinct tag + virtual-tag paths; honors the filter prefix.- Live docker-dev
/run: on/scripts/{id}—ctx.shows members; bad code shows a red squiggle;ctx.GetTag("lists tag paths; Format works; theme toggle. Then the VirtualTagModal — open it, pick a script, edit the inline panel, see the "used by N" notice, save, reopen to confirm persistence.
Done = build clean + dotnet test green + live /run pass.
Out of scope (follow-ups)
- Creating a brand-new
Scriptinline from the VirtualTagModal. - Per-virtual-tag return-type-aware diagnostics (analysis uses
objectreturn). - Embedding the editor in any surface beyond ScriptEdit + VirtualTagModal.
- Sharing the analysis backend as a cross-repo package with scadabridge (this is a copy-and-adapt fork; the context models differ).
Hard rules (carried from prior AdminUI work)
- Stage by path — never
git add .; never stagesql_login.txtorsrc/Server/ZB.MOM.WW.OtOpcUa.Host/pki/; never echo the gateway API key into a new tracked file; no force-push; no--no-verify. - The agent does not sign in to the AdminUI — the user signs in for live verification.
- No Configuration entity/migration change is required by this feature (the
catalog reads existing tables;
Script/VirtualTagschemas are unchanged).