docs(scripting): Monaco script-editor guide + refresh Scripts page banner

This commit is contained in:
Joseph Doherty
2026-06-09 15:50:49 -04:00
parent 72fcde5b44
commit 4d12088fa2
4 changed files with 365 additions and 3 deletions
+13
View File
@@ -158,3 +158,16 @@ Address pickers in AdminUI support live browse for OpcUaClient and Galaxy driver
The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`.
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (OpcUaClient/Galaxy/Historian.Wonderware) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
## Scripting / Script Editor
C# virtual-tag scripts are authored in a **Roslyn-backed Monaco editor** on the
ScriptEdit page (`/scripts/{id}`) and inline inside the virtual-tag modal on the
`/uns` page. The editor provides completions, live diagnostics, hover, signature
help, document formatting, and tag-path completions inside `ctx.GetTag("…")` /
`ctx.SetVirtualTag("…")` literals — all backed by the same compiler context the
runtime publish gate uses, so what the editor accepts/rejects matches publish
exactly. The backend lives in
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/` (six minimal-API
endpoints under `/api/script-analysis/*`, gated by the `FleetAdmin` policy).
See `docs/ScriptEditor.md` for the full guide.
+1
View File
@@ -35,6 +35,7 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess
| [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` |
| [HistoricalDataAccess.md](v1/HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability (v1 archive) |
| [VirtualTags.md](VirtualTags.md) | `Core.Scripting` + `Core.VirtualTags` — Roslyn script sandbox, engine, dispatch alongside driver tags |
| [ScriptEditor.md](ScriptEditor.md) | Monaco script editor — Roslyn-backed IntelliSense (completions, diagnostics, hover, tag-path) for C# virtual-tag scripts; `AdminUI/ScriptAnalysis/` backend |
| [ScriptedAlarms.md](ScriptedAlarms.md) | `Core.ScriptedAlarms` — script-predicate `IAlarmSource` + Part 9 state machine |
One Core subsystem is shipped without a dedicated top-level doc; see the section in the linked doc:
+348
View File
@@ -0,0 +1,348 @@
# 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`](plans/2026-06-09-monaco-script-editor-design.md).
---
## Overview
Virtual-tag scripts are C# expression bodies (see [VirtualTags.md](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: `LiteralExpressionSyntax``ArgumentSyntax`
(first argument only) → `ArgumentListSyntax``InvocationExpressionSyntax`
`MemberAccessExpressionSyntax`. 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 → `ForbiddenTypeAnalyzer``DependencyExtractor`) 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).
@@ -18,9 +18,9 @@
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Scripts are fleet-wide expression compilations referenced by virtual tags and scripted
alarms. The default language is C#; expansion of the editor (Monaco syntax, dependency
introspection) lands in Phase D.2.
Scripts are fleet-wide C# expression compilations referenced by virtual tags and scripted
alarms. The editor provides Roslyn-backed IntelliSense — completions, diagnostics, hover,
and tag-path suggestions for <code>ctx.GetTag</code> / <code>ctx.SetVirtualTag</code> literals.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">