Files
lmxopcua/docs/ScriptEditor.md

18 KiB

Monaco Script Editor (Roslyn-backed IntelliSense)

The script editor gives AdminUI users a first-class C# authoring experience for virtual-tag scripts: completions, diagnostics, hover, signature help, document formatting, and context-aware tag-path completions — all backed by the same Roslyn compiler the runtime publish gate uses.

See the design doc for the original rationale and brainstorming record: docs/plans/2026-06-09-monaco-script-editor-design.md.


Overview

Virtual-tag scripts are C# expression bodies (see VirtualTags.md) compiled at publish time by ScriptEvaluator against a restricted ScriptSandbox. Before this feature the AdminUI's script page used a CDN Monaco instance wired over a hidden textarea via an eval + Task.Delay(50) race — no IntelliSense, fragile in air-gapped environments, and the editor accepted code that publish would reject.

The replacement:

  • Vendored Monaco served locally — no CDN, air-gap safe.
  • Reusable MonacoEditor.razor component wired into both the ScriptEdit page (/scripts/{id}) and the virtual-tag inline script panel in the UNS TagModal.
  • Full IntelliSense via a Roslyn backend that analyses the user's source inside the exact evaluator wrapper ScriptEvaluator compiles, so what the editor accepts/rejects matches publish exactly.

Architecture

┌──────────────────────────── AdminUI (Blazor Server) ───────────────────────────┐
│                                                                                 │
│  Components/Shared/MonacoEditor.razor  ◄── Value/ValueChanged, Height,          │
│        │   ReadOnly, ShowToolbar, theme/Format/wrap/minimap 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, FleetAdmin)     │
│        ▼                                                                        │
│  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, loaded by monaco-init.js)           │
└─────────────────────────────────────────────────────────────────────────────────┘
                      │ references
                      ▼
  Core.Scripting + Scripting.Abstractions  (ScriptSandbox, VirtualTagContext,
  ScriptGlobals<>, ForbiddenTypeAnalyzer, DependencyExtractor)
  Configuration  (OtOpcUaConfigDbContext → tag / virtual-tag paths for catalog)

MonacoEditor.razor

src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor

A reusable Blazor Server component. Key parameters:

Parameter Type Notes
Value string Two-way bound script source
ValueChanged EventCallback<string> Fires on every model change
Height string CSS height (default 400px)
ReadOnly bool Disables editing; IntelliSense still works
ShowToolbar bool Shows the theme/Format/wrap/minimap toolbar

The component creates the Monaco editor in OnAfterRenderAsync, registers a DotNetObjectReference so JS can call back into Blazor on value change, and uses a GUID-keyed container so multiple instances on the same page coexist safely. External Value changes are pushed to the editor via window.MonacoBlazor.setValue.

monaco-init.js

src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js

Exposes window.MonacoBlazor with createEditor / setValue / getValue / setMarkers / setEditorOption / format / dispose. Registers the six Monaco language providers for the csharp language at load time; each provider POSTs to the corresponding /api/script-analysis/* endpoint. Diagnostics are debounced (~500 ms) to avoid firing on every keystroke.

ScriptAnalysisService

src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs

The analysis seam is the private Analyze(userSource) method, which:

  1. Builds the wrapped document — the user's source is appended after the OtOpcUa evaluator preamble (usings from ScriptSandbox.Build, the CompiledScript.Run(ScriptGlobals<VirtualTagContext>) wrapper, var ctx = globals.ctx;, then #line 1 so Roslyn reports user-source coordinates for diagnostics). Return type is object so one shared script is valid regardless of any virtual tag's DataType.
  2. Parses the wrapped document as a CSharpSyntaxTree and creates a CSharpCompilation against the ScriptSandbox reference set.
  3. Returns the SemanticModel, SyntaxTree, and the preamble byte offset (used by every capability to map editor coordinates to wrapped-document offsets).

HTTP Endpoints

All six endpoints live under the /api/script-analysis group, registered by ScriptAnalysisEndpoints.MapScriptAnalysisEndpoints() (called from EndpointRouteBuilderExtensions.AddAdminUI / Host/Program.cs) and gated by the FleetAdmin authorization policy (requires the Administrator role).

Route Request DTO Response DTO Purpose
POST /api/script-analysis/diagnostics DiagnoseRequest(Code) DiagnoseResponse(Markers) Roslyn + ForbiddenType + dynamic-path error markers
POST /api/script-analysis/completions CompletionsRequest(CodeText, Line, Column) CompletionsResponse(Items) Scope, dot-member, and tag-path completions
POST /api/script-analysis/hover HoverRequest(CodeText, Line, Column) HoverResponse(Markdown?) Type + XML doc markdown
POST /api/script-analysis/signature-help SignatureHelpRequest(CodeText, Line, Column) SignatureHelpResponse(Label?, Parameters?, ActiveParameter) Parameter help
POST /api/script-analysis/format FormatRequest(Code) FormatResponse(Code) NormalizeWhitespace() formatting
POST /api/script-analysis/inlay-hints InlayHintsRequest(Code) InlayHintsResponse(Hints) Inline type hints (stub — returns empty)

DTO definitions live in ScriptAnalysis/ScriptAnalysisContracts.cs.

DiagnosticMarker fields: Severity (Monaco values: 8 = error, 4 = warning, 2 = info, 1 = hint), StartLineNumber, StartColumn, EndLineNumber, EndColumn, Message, Code.

CompletionItem fields: Label, InsertText, Detail, Kind (Monaco kind string: "Method", "Property", "Field", "Variable", "Class", etc.), InsertTextRules (0 for plain text, 4 for snippet).


Diagnostics Composition

The diagnostics endpoint combines three sources and returns errors only:

1. Roslyn compile diagnostics

compilation.GetDiagnostics() filtered to DiagnosticSeverity.Error. Warnings are intentionally excluded: the canonical passthrough pattern return ctx.GetTag("X").Value; triggers nullable warning CS8605 under NullableContextOptions.Enable but compiles and publishes fine — flagging it in the editor would be misleading noise that does not reflect publish reality.

Line numbers are read from GetMappedLineSpan() (not GetLineSpan()). GetMappedLineSpan honours the #line 1 directive in the preamble, so it returns user-source coordinates directly. Diagnostics whose SourceSpan.Start < preambleLength are dropped — they are phantom wrapper diagnostics (e.g. CS0161 "not all code paths return a value" on the synthesized Run method body when the user has not yet typed return) and are not the user's fault.

2. ForbiddenTypeAnalyzer

ForbiddenTypeAnalyzer.Analyze(compilation) walks the wrapped syntax tree and resolves every referenced symbol against the sandbox deny-list (namespace prefixes System.IO, System.Net, System.Diagnostics, System.Reflection, System.Threading.Tasks, etc., plus type-granular denials like System.Environment and System.Threading.Thread). Each violation is mapped via tree.GetMappedLineSpan(span) to user-source coordinates and emitted as an error marker with code OTSCRIPT_FORBIDDEN.

3. DependencyExtractor

DependencyExtractor.Extract(code).Rejections enumerates every ctx.GetTag / ctx.SetVirtualTag call whose first argument is NOT a string literal (variable, concatenation, interpolation). Each rejection carries a TextSpan in user-source coordinates, emitted as code OTSCRIPT_DYNPATH. This is the exact same check publish enforces — the editor shows the squiggle where the literal must appear.


Tag-Path Completion

When the caret is inside the first string-literal argument of a GetTag("…") or SetVirtualTag("…") invocation, the completion provider delegates to IScriptTagCatalog instead of the semantic model.

IScriptTagCatalog contract

ScriptAnalysis/IScriptTagCatalog.cs
GetPathsAsync(string? filter, CancellationToken) returns the distinct runtime-resolvable keys a script may pass to ctx.GetTag / ctx.SetVirtualTag, optionally filtered by a case-insensitive StartsWith prefix. The concrete implementation ScriptTagCatalog reads Tag + VirtualTag rows from OtOpcUaConfigDbContext and projects them as follows:

Tag category Resolvable key emitted
Equipment driver tag (EquipmentId != null) Driver FullName extracted from Tag.TagConfig JSON — the verified DependencyMux key
SystemPlatform / Galaxy tag (EquipmentId == null) MXAccess dot-ref: FolderPath.Name when a folder is set, else Name
Virtual tag Leaf Name (best-effort)

Why only resolvable keys? The live runtime resolves a ctx.GetTag("X") literal against DriverInstanceActor.AttributeValuePublished.FullReference, which is the FullName field from Tag.TagConfig (see Phase7Composer.ExtractTagFullName + EquipmentNodeWalker.ExtractFullName). The UNS-path engine (Core.VirtualTags.VirtualTagEngine, keyed by the slash-joined Enterprise/Site/Area/Line/Equipment/TagName browse path) is dormant — it is not wired into the host — so UNS browse paths never resolve at runtime and are intentionally NOT offered as completions.

The catalog is scoped per Blazor circuit; each call creates and disposes its own DbContext via the pooled factory (same pattern as UnsTreeService). Results are bounded to 200 entries to keep the completion list responsive on large fleets.

Literal gate

TryGetTagPathLiteral identifies the tag-path context by climbing from the syntax token under the caret to: LiteralExpressionSyntaxArgumentSyntax (first argument only) → ArgumentListSyntaxInvocationExpressionSyntaxMemberAccessExpressionSyntax. The method name check is method is "GetTag" or "SetVirtualTag" — it matches by method name only, not by receiver type, so anything.GetTag("…") would also trigger tag-path completion. This is harmless in the sandbox (no other GetTag methods exist in the allowed reference set) and keeps the detection simple.


Vendoring / Air-gap Safety

Monaco is served from _content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/monaco/vs/ (the Blazor static-asset path for the AdminUI RCL). The loader script is at _content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js. No CDN is contacted at runtime. The legacy CDN loader (monaco-loader.js) and the eval/Task.Delay injection previously in ScriptEdit.razor have been removed.


Wire-in Points

ScriptEdit page (/scripts/{id})

src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor

Hosts <MonacoEditor @bind-Value="_form.SourceCode">. On save, SHA-256 is recomputed from the editor value and stored in Script.SourceHash — unchanged from the pre-Monaco save path.

VirtualTagModal (/uns TagModal for virtual tags)

src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor

When the selected script has a ScriptId, an inline "Script source" panel renders <MonacoEditor> bound to the script source. The panel shows a "Used by N virtual tag(s)" notice (populated from IUnsTreeService.CountVirtualTagsUsingScriptAsync). A dedicated Save script button calls IUnsTreeService.UpdateScriptSourceAsync, which performs a RowVersion-guarded update of the Script row — a separate concurrency unit from the virtual-tag Create/Save operation. Creating a brand-new script inline from the modal is a follow-up (not v1).

Three IUnsTreeService methods back the inline panel: GetScriptSourceAsync / CountVirtualTagsUsingScriptAsync / UpdateScriptSourceAsync.


How to Extend

Adding a new completion source

  1. Implement a new case in ScriptAnalysisService.CompleteAsync. The Analyze(code) seam gives you the SemanticModel, SyntaxTree, and preamble offset.
  2. Add the corresponding request/response DTOs to ScriptAnalysisContracts.cs if a new endpoint is needed, or fold into an existing endpoint with an extra field.
  3. Register the new Monaco provider in monaco-init.js using the same fetch('/api/script-analysis/…', { method: 'POST', … }) pattern.

Adding a new analysis check (diagnostics)

Extend Diagnose in ScriptAnalysisService. The existing three-source pattern (Roslyn → ForbiddenTypeAnalyzerDependencyExtractor) is additive: add a fourth source and append to markers. Keep severity at 8 (Monaco error) for anything that blocks publish; use 4 (warning) for advisory-only hints.

Changing the tag-path catalog source

Replace or extend the ScriptTagCatalog implementation of IScriptTagCatalog. Register the replacement in EndpointRouteBuilderExtensions.AddAdminUI (or inject via the DI container as the IScriptTagCatalog scoped service).


Known Limitations / Follow-ups

  • Tag-path completion shows resolvable keys only. Surfacing the UNS browse path as a completion detail (a non-inserted hint shown alongside the resolvable key) for discoverability is a tracked follow-up.
  • Literal gate matches by method name, not receiver. anything.GetTag("…") would also trigger tag-path completion (not just ctx.GetTag("…")). Harmless in the sandbox today — no other GetTag methods exist in the allowed reference set — but tighter binding to ctx / VirtualTagContext is a follow-up.
  • Analysis runs synchronous Roslyn (CPU-bound). Each endpoint call builds a CSharpCompilation synchronously. This is intentional: the endpoints are admin-only, client-side debounced (~500 ms), and Task.Run offload adds unnecessary complexity for infrequent admin use. If concurrency becomes a concern under heavy admin usage, wrapping each Analyze call in Task.Run is the correct fix.
  • InlayHints is a stub. /api/script-analysis/inlay-hints returns an empty list. Filling it in (parameter name hints from the semantic model) is a follow-up task.
  • Language providers register globally at Monaco load. The six providers are registered for the csharp language on the global Monaco instance (loaded once per page in App.razor). There is no impact today because only Administrator-reachable pages host a MonacoEditor, but if a read-only surface ever embeds the editor the providers will fire (harmlessly — they just get 401 responses from the FleetAdmin-gated endpoints and return empty results).
  • Creating a new script from the VirtualTagModal inline panel is deferred. The current inline panel loads and saves an existing Script row; creating a brand-new script requires a separate create flow and is a follow-up.

Testing

Unit tests live in tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/:

File Covers
DiagnoseTests.cs Clean script (no markers), Roslyn error, ForbiddenTypeAnalyzer violation, dynamic-path rejection, #line mapping
CompletionTests.cs Scope completions, dot-member completions after ctx.
TagPathCompletionTests.cs Tag-path literal completions inside GetTag/SetVirtualTag via a fake IScriptTagCatalog
HoverSignatureTests.cs Hover markdown, signature-help parameter tracking
FormatTests.cs NormalizeWhitespace round-trip
ScriptTagCatalogTests.cs Equipment-tag FullName, Galaxy dot-ref, virtual-tag leaf Name, filter prefix, MaxResults bound

tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs covers GetScriptSourceAsync / CountVirtualTagsUsingScriptAsync / UpdateScriptSourceAsync via the in-memory EF pattern.

No bUnit tests exist for MonacoEditor.razor or VirtualTagModal.razor — the AdminUI has no bUnit dependency. Razor/JS correctness was proven by live docker-dev /run (ScriptEdit + UNS TagModal with Monaco, diagnostics, hover, tag-path completions, Format, inline script save).