From 4d12088fa2e803120463150909be7cf3a7e452ae Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 9 Jun 2026 15:50:49 -0400 Subject: [PATCH] docs(scripting): Monaco script-editor guide + refresh Scripts page banner --- CLAUDE.md | 13 + docs/README.md | 1 + docs/ScriptEditor.md | 348 ++++++++++++++++++ .../Components/Pages/Scripts.razor | 6 +- 4 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 docs/ScriptEditor.md diff --git a/CLAUDE.md b/CLAUDE.md index 9ba7ba5b..172ed90e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `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. diff --git a/docs/README.md b/docs/README.md index a05013e3..6bf20647 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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: diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md new file mode 100644 index 00000000..5da064fe --- /dev/null +++ b/docs/ScriptEditor.md @@ -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) 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` | 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)` 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 ``. 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 `` 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). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor index a6910fdb..e8965d25 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor @@ -18,9 +18,9 @@ else {
- 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 ctx.GetTag / ctx.SetVirtualTag literals.