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/docs/plans/2026-06-09-monaco-script-editor.md.tasks.json b/docs/plans/2026-06-09-monaco-script-editor.md.tasks.json index 6259e675..d6d406c6 100644 --- a/docs/plans/2026-06-09-monaco-script-editor.md.tasks.json +++ b/docs/plans/2026-06-09-monaco-script-editor.md.tasks.json @@ -1,21 +1,22 @@ { "planPath": "docs/plans/2026-06-09-monaco-script-editor.md", "branch": "feat/monaco-script-editor", + "status": "complete", "tasks": [ - {"id": 163, "planTask": 0, "subject": "Task 0: AdminUI project references + feature branch", "status": "pending"}, - {"id": 164, "planTask": 1, "subject": "Task 1: Vendor Monaco + MonacoEditor.razor + minimal monaco-init.js + App.razor", "status": "pending", "blockedBy": [163]}, - {"id": 165, "planTask": 2, "subject": "Task 2: ScriptAnalysis contracts + service seam + endpoints + DI", "status": "pending", "blockedBy": [163]}, - {"id": 166, "planTask": 3, "subject": "Task 3: Diagnostics (Roslyn + ForbiddenTypeAnalyzer + DependencyExtractor)", "status": "pending", "blockedBy": [165]}, - {"id": 167, "planTask": 4, "subject": "Task 4: Completions (scope + dot-member)", "status": "pending", "blockedBy": [166]}, - {"id": 168, "planTask": 5, "subject": "Task 5: IScriptTagCatalog (tag + virtual-tag path provider)", "status": "pending", "blockedBy": [165]}, - {"id": 169, "planTask": 6, "subject": "Task 6: Tag-path string-literal completion", "status": "pending", "blockedBy": [167, 168]}, - {"id": 170, "planTask": 7, "subject": "Task 7: Hover + signature help", "status": "pending", "blockedBy": [169]}, - {"id": 171, "planTask": 8, "subject": "Task 8: Format (+ InlayHints stub)", "status": "pending", "blockedBy": [170]}, - {"id": 172, "planTask": 9, "subject": "Task 9: Wire 6 Monaco language providers in monaco-init.js", "status": "pending", "blockedBy": [164, 171]}, - {"id": 173, "planTask": 10, "subject": "Task 10: Swap ScriptEdit page to MonacoEditor", "status": "pending", "blockedBy": [164]}, - {"id": 174, "planTask": 11, "subject": "Task 11: VirtualTagModal inline script-source panel", "status": "pending", "blockedBy": [164]}, - {"id": 175, "planTask": 12, "subject": "Task 12: Live verification in docker-dev", "status": "pending", "blockedBy": [172, 173, 174]}, - {"id": 176, "planTask": 13, "subject": "Task 13: Docs + memory + finish branch", "status": "pending", "blockedBy": [175]} + {"id": 163, "planTask": 0, "subject": "Task 0: AdminUI project references + feature branch", "status": "completed", "commit": "a2dbc5e"}, + {"id": 164, "planTask": 1, "subject": "Task 1: Vendor Monaco + MonacoEditor.razor + minimal monaco-init.js + App.razor", "status": "completed", "commit": "9afb2d2"}, + {"id": 165, "planTask": 2, "subject": "Task 2: ScriptAnalysis contracts + service seam + endpoints + DI", "status": "completed", "commit": "b54a6ad"}, + {"id": 166, "planTask": 3, "subject": "Task 3: Diagnostics (Roslyn + ForbiddenTypeAnalyzer + DependencyExtractor)", "status": "completed", "commit": "6a9b052"}, + {"id": 167, "planTask": 4, "subject": "Task 4: Completions (scope + dot-member)", "status": "completed", "commit": "93f5a74"}, + {"id": 168, "planTask": 5, "subject": "Task 5: IScriptTagCatalog (tag + virtual-tag path provider)", "status": "completed", "commit": "d143493"}, + {"id": 169, "planTask": 6, "subject": "Task 6: Tag-path string-literal completion", "status": "completed", "commit": "521fb61"}, + {"id": 170, "planTask": 7, "subject": "Task 7: Hover + signature help", "status": "completed", "commit": "9104b6c"}, + {"id": 171, "planTask": 8, "subject": "Task 8: Format (+ InlayHints stub)", "status": "completed", "commit": "4a2f7e3"}, + {"id": 172, "planTask": 9, "subject": "Task 9: Wire 6 Monaco language providers in monaco-init.js", "status": "completed", "commit": "071bed5"}, + {"id": 173, "planTask": 10, "subject": "Task 10: Swap ScriptEdit page to MonacoEditor", "status": "completed", "commit": "088fc50"}, + {"id": 174, "planTask": 11, "subject": "Task 11: VirtualTagModal inline script-source panel", "status": "completed", "commit": "fc7dc3b"}, + {"id": 175, "planTask": 12, "subject": "Task 12: Live verification in docker-dev", "status": "completed", "note": "all checks passed: render, completion, diagnostics, format, tag-path, modal panel"}, + {"id": 176, "planTask": 13, "subject": "Task 13: Docs + memory + finish branch", "status": "completed", "commit": "4d12088"} ], "lastUpdated": "2026-06-09" } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor index 308f98f2..7255b31d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor @@ -22,6 +22,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor index f7dd8ab2..0010d172 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -13,7 +13,6 @@ @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject IDbContextFactory DbFactory @inject NavigationManager Nav -@inject IJSRuntime JS

@(IsNew ? "New script" : "Edit script")

@@ -57,15 +56,8 @@ else
Source
- @* The textarea stays in the DOM and remains Blazor's source of truth. Monaco - mounts a
beside it (textarea hides), and the loader's onDidChangeModelContent - handler mirrors edits back into the textarea + fires the input event so @bind - picks them up. Falls back to the textarea gracefully if Monaco's CDN is - unreachable (air-gapped deployments — see monaco-loader.js). *@ - -
SHA-256 hash is computed automatically on save. Monaco editor attaches over the textarea on render.
+ +
SHA-256 hash is computed automatically on save.
@@ -110,24 +102,6 @@ else _loaded = true; } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender || !_loaded) return; - // Inject loader once, then attach over the textarea. Failures are silent — the page - // is fully usable via the underlying textarea if Monaco's CDN is unreachable. - try - { - await JS.InvokeVoidAsync("eval", "if (!document.querySelector('script[data-otopcua=monaco-loader]')) { var s=document.createElement('script'); s.src='/_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-loader.js'; s.dataset.otopcua='monaco-loader'; document.head.appendChild(s); }"); - // Wait a tick for the loader IIFE to register window.otOpcUaScriptEditor, then attach. - await Task.Delay(50); - await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", "script-source"); - } - catch - { - // Textarea remains the editor — no-op. - } - } - private async Task SubmitAsync() { _busy = true; _error = null; 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.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor new file mode 100644 index 00000000..6b4e549d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor @@ -0,0 +1,166 @@ +@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared +@using Microsoft.Extensions.Logging +@implements IAsyncDisposable +@inject IJSRuntime JS +@inject Microsoft.Extensions.Logging.ILogger Logger + +@if (ShowToolbar) +{ +
+ + + + +
+} + +
+ +@code { + [Parameter] public string Value { get; set; } = ""; + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public string Language { get; set; } = "csharp"; + [Parameter] public string Height { get; set; } = "320px"; + [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public bool ShowToolbar { get; set; } = true; + + /// + /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic + /// debounce). The marker DTO is not modelled yet — typed as object[] until a + /// later task wires the Roslyn-backed diagnostics. + /// + [Parameter] public EventCallback MarkersChanged { get; set; } + + private ElementReference _hostRef; + private DotNetObjectReference? _dotNetRef; + private readonly string _id = Guid.NewGuid().ToString("N"); + private string _lastSentValue = ""; + private bool _initialized; + private bool _wrap; + private bool _minimap; + private bool _dark; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotNetRef = DotNetObjectReference.Create(this); + _lastSentValue = Value ?? ""; + try + { + await JS.InvokeVoidAsync( + "MonacoBlazor.createEditor", + _id, + _hostRef, + new + { + value = Value ?? "", + language = Language, + readOnly = ReadOnly + }, + _dotNetRef); + _initialized = true; + } + catch (InvalidOperationException) + { + // Prerendering: JS interop is not available yet — the next + // (interactive) render retries. Expected, not logged. + } + catch (JSDisconnectedException) + { + // Circuit disconnected before init completed — nothing to do. + } + catch (JSException ex) + { + // A genuine Monaco init failure — surface it instead of hiding it. + Logger.LogError(ex, "Monaco editor {EditorId} failed to initialize.", _id); + } + } + else if (_initialized && (Value ?? "") != _lastSentValue) + { + _lastSentValue = Value ?? ""; + await SafeInvokeAsync("MonacoBlazor.setValue", "set editor value", _id, _lastSentValue); + } + } + + /// + /// Invokes a Monaco JS function, swallowing the expected disconnect case but + /// logging any genuine JS error so failures are not silent. + /// + private async ValueTask SafeInvokeAsync(string fn, string action, params object?[] args) + { + try + { + await JS.InvokeVoidAsync(fn, args); + } + catch (JSDisconnectedException) + { + // Circuit gone — the editor no longer exists; nothing to log. + } + catch (JSException ex) + { + Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to {Action}.", _id, action); + } + } + + [JSInvokable] + public Task OnValueChanged(string newValue) + { + var normalized = newValue ?? ""; + if (normalized == _lastSentValue) + return Task.CompletedTask; + _lastSentValue = normalized; + return ValueChanged.InvokeAsync(_lastSentValue); + } + + [JSInvokable] + public Task OnMarkersChanged(object[] markers) => + MarkersChanged.InvokeAsync(markers ?? Array.Empty()); + + /// Programmatic scroll-to-line (called by the problems panel). + public async Task RevealLineAsync(int line, int column = 1) + { + if (!_initialized) return; + await SafeInvokeAsync("MonacoBlazor.revealLine", "reveal line", _id, line, column); + } + + private async Task FormatAsync() + { + if (!_initialized) return; + await SafeInvokeAsync("MonacoBlazor.format", "format document", _id); + } + + private async Task ToggleWrap() + { + _wrap = !_wrap; + await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle word wrap", _id, "wordWrap", _wrap ? "on" : "off"); + } + + private async Task ToggleMinimap() + { + _minimap = !_minimap; + await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle minimap", _id, "minimap", new { enabled = _minimap }); + } + + private async Task ToggleTheme() + { + _dark = !_dark; + await SafeInvokeAsync("MonacoBlazor.setEditorOption", "toggle theme", _id, "theme", _dark ? "vs-dark" : "vs"); + } + + public async ValueTask DisposeAsync() + { + if (_initialized) + { + // Disposal commonly races a circuit disconnect — JSDisconnectedException + // here is expected and silent; a real JSException is still logged. + await SafeInvokeAsync("MonacoBlazor.dispose", "dispose editor", _id); + } + _dotNetRef?.Dispose(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor index a1767c05..db93f2c1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor @@ -47,7 +47,8 @@
- + @foreach (var (id, display) in Scripts) { @@ -57,6 +58,48 @@
+ + @* Inline script-source editor. Shown only when a script is bound. This panel saves + the SHARED Script row on its OWN concurrency-guarded button — it is deliberately + separate from the virtual-tag Create/Save below and never touches _form or closes + the modal. *@ + @if (!string.IsNullOrEmpty(_form.ScriptId) && _scriptLoaded) + { +
+
+ Script source + +
+ @if (_scriptExpanded) + { +
+
+ Editing shared script "@_scriptName" — used by + @_scriptUsageCount virtual tag(s). Changes affect all of them. +
+ + @if (!string.IsNullOrWhiteSpace(_scriptError)) + { +
@_scriptError
+ } + else if (_scriptSaved) + { +
Script saved.
+ } +
+ +
+
+ } +
+ }
@@ -92,8 +135,8 @@ }