docs(scripting): Monaco script-editor guide + refresh Scripts page banner
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user