From 0687bb2e2d5ffd82d212ea3f9b9a522767fc6abd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 19:59:18 -0400 Subject: [PATCH] =?UTF-8?q?Phase=207=20Stream=20F=20=E2=80=94=20Admin=20UI?= =?UTF-8?q?=20for=20scripts=20+=20test=20harness=20+=20historian=20diagnos?= =?UTF-8?q?tics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the draft-editor tab + page surface for authoring Phase 7 virtual tags and scripted alarms, plus the /alarms/historian operator diagnostics page. Monaco loads from CDN via a progressive-enhancement JS shim — the textarea works immediately so the page is functional even if the CDN is unreachable. ## New services (Admin) - ScriptService — CRUD for Script entity. SHA-256 SourceHash recomputed on save so Core.Scripting's CompiledScriptCache hits on re-publish of unchanged source + misses when the source actually changes. - VirtualTagService — CRUD for VirtualTag, with Enabled toggle. - ScriptedAlarmService — CRUD for ScriptedAlarm + lookup of persistent ScriptedAlarmState (logical-id-keyed per plan decision #14). - ScriptTestHarnessService — pre-publish dry-run. Enforces plan decision #22: only inputs the DependencyExtractor identifies can be supplied. Missing / extra synthetic inputs surface as dedicated outcomes. Captures SetVirtualTag writes + Serilog events from the script so the operator can see both the output + the log output before publishing. - HistorianDiagnosticsService — surfaces the local-process IAlarmHistorianSink state on /alarms/historian. Null sink reports Disabled + swallows retry. Live SqliteStoreAndForwardSink reports real queue depth + last-error + drain state and routes the Retry-dead-lettered button through. ## New UI - ScriptsTab.razor (inside DraftEditor tabs) — list + create/edit/delete scripts with Monaco editor + dependency preview + test-harness run panel showing output + writes + log emissions. - ScriptEditor.razor — reusable Monaco-backed textarea. Loads editor from CDN via wwwroot/js/monaco-loader.js. Textarea stays authoritative for Blazor binding; Monaco mirrors into it on every keystroke. - AlarmsHistorian.razor (/alarms/historian) — queue depth + dead-letter depth + drain state badge + last-error banner + Retry-dead-lettered button. - DraftEditor.razor — new "Scripts" tab. ## DI wiring All five services registered in Program.cs. Null historian sink bound at Admin composition time (real SqliteStoreAndForwardSink lives in the Server process). ## Tests — 13/13 Phase7ServicesTests covers: - ScriptService: Add generates logical id + hash, Update recomputes hash on source change, Update same-source keeps hash (cache-hit preservation), Delete is idempotent - VirtualTagService: round-trips trigger flags, Enabled toggle works - ScriptedAlarmService: HistorizeToAveva defaults true per plan decision #15 - ScriptTestHarness: successful run captures output + writes, rejects missing / extra inputs, rejects non-literal paths, compile errors surface as Threw - HistorianDiagnosticsService: null sink reports Disabled + retry returns 0 --- .../Components/Pages/AlarmsHistorian.razor | 79 ++++++ .../Pages/Clusters/DraftEditor.razor | 2 + .../Pages/Clusters/ScriptEditor.razor | 41 ++++ .../Pages/Clusters/ScriptsTab.razor | 224 ++++++++++++++++++ src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs | 12 + .../Services/HistorianDiagnosticsService.cs | 32 +++ .../Services/ScriptService.cs | 66 ++++++ .../Services/ScriptTestHarnessService.cs | 121 ++++++++++ .../Services/ScriptedAlarmService.cs | 55 +++++ .../Services/VirtualTagService.cs | 53 +++++ .../ZB.MOM.WW.OtOpcUa.Admin.csproj | 2 + .../wwwroot/js/monaco-loader.js | 59 +++++ .../Phase7ServicesTests.cs | 196 +++++++++++++++ 13 files changed, 942 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/Phase7ServicesTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor new file mode 100644 index 0000000..b8dcbfe --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor @@ -0,0 +1,79 @@ +@page "/alarms/historian" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian +@inject HistorianDiagnosticsService Diag + +

Alarm historian

+

Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.

+ +
+
+
+
+ Drain state +

@_status.DrainState

+
+
+ Queue depth +

@_status.QueueDepth.ToString("N0")

+
+
+ Dead-letter depth +

@_status.DeadLetterDepth.ToString("N0")

+
+
+ Last success +

@(_status.LastSuccessUtc?.ToString("u") ?? "—")

+
+
+ + @if (!string.IsNullOrEmpty(_status.LastError)) + { +
+ Last error: @_status.LastError +
+ } +
+
+ +
+ + +
+ +@if (_retryResult is not null) +{ +
Requeued @_retryResult row(s) for retry.
+} + +@code { + private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled); + private int? _retryResult; + + protected override void OnInitialized() => _status = Diag.GetStatus(); + + private Task RefreshAsync() + { + _status = Diag.GetStatus(); + _retryResult = null; + return Task.CompletedTask; + } + + private Task RetryDeadLetteredAsync() + { + _retryResult = Diag.TryRetryDeadLettered(); + _status = Diag.GetStatus(); + return Task.CompletedTask; + } + + private static string BadgeFor(HistorianDrainState s) => s switch + { + HistorianDrainState.Idle => "bg-success", + HistorianDrainState.Draining => "bg-info", + HistorianDrainState.BackingOff => "bg-warning text-dark", + HistorianDrainState.Disabled => "bg-secondary", + _ => "bg-secondary", + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor index dfef4b9..e92f284 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor @@ -23,6 +23,7 @@ +
@@ -32,6 +33,7 @@ else if (_tab == "namespaces") { } else if (_tab == "drivers") { } else if (_tab == "acls") { } + else if (_tab == "scripts") { }
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor new file mode 100644 index 0000000..2f4339c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor @@ -0,0 +1,41 @@ +@using Microsoft.AspNetCore.Components.Web +@inject IJSRuntime JS + +@* + Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement: + textarea renders immediately, Monaco mounts via JS interop after first render. + Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js + pulls the CDN bundle). + + Stream F keeps the interop surface small — bind `Source` two-way, and the parent + tab re-renders on change for the dependency preview. The test-harness button + lives in the parent so one editor can drive multiple script types. +*@ + +
+ +
+ +@code { + [Parameter] public string Source { get; set; } = string.Empty; + [Parameter] public EventCallback SourceChanged { get; set; } + + private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId); + } + catch (JSException) + { + // Monaco bundle not yet loaded on this page — textarea fallback is + // still functional. + } + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor new file mode 100644 index 0000000..b0d5eb7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor @@ -0,0 +1,224 @@ +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Core.Abstractions +@using ZB.MOM.WW.OtOpcUa.Core.Scripting +@inject ScriptService ScriptSvc +@inject ScriptTestHarnessService Harness + +
+
+

Scripts

+ C# (Roslyn). Used by virtual tags + scripted alarms. +
+ +
+ + + +@if (_loading) {

Loading…

} +else if (_scripts.Count == 0 && _editing is null) +{ +
No scripts yet in this draft.
+} +else +{ +
+
+
+ @foreach (var s in _scripts) + { + + } +
+
+
+ @if (_editing is not null) + { +
+
+ @(_isNew ? "New script" : _editing.Name) +
+ @if (!_isNew) + { + + } + +
+
+
+
+ + +
+ + + +
+ + +
+ + @if (_dependencies is not null) + { +
+ Inferred reads + @if (_dependencies.Reads.Count == 0) { none } + else + { +
    + @foreach (var r in _dependencies.Reads) {
  • @r
  • } +
+ } + Inferred writes + @if (_dependencies.Writes.Count == 0) { none } + else + { +
    + @foreach (var w in _dependencies.Writes) {
  • @w
  • } +
+ } + @if (_dependencies.Rejections.Count > 0) + { +
+ Non-literal paths rejected: +
    + @foreach (var r in _dependencies.Rejections) {
  • @r.Message
  • } +
+
+ } +
+ } + + @if (_testResult is not null) + { +
+ Harness result: @_testResult.Outcome + @if (_testResult.Outcome == ScriptTestOutcome.Success) + { +
Output: @(_testResult.Output?.ToString() ?? "null")
+ @if (_testResult.Writes.Count > 0) + { +
Writes: +
    + @foreach (var kv in _testResult.Writes) {
  • @kv.Key = @(kv.Value?.ToString() ?? "null")
  • } +
+
+ } + } + @if (_testResult.Errors.Count > 0) + { +
+ @foreach (var e in _testResult.Errors) {
@e
} +
+ } + @if (_testResult.LogEvents.Count > 0) + { +
Script log output: +
    + @foreach (var e in _testResult.LogEvents) {
  • [@e.Level] @e.RenderMessage()
  • } +
+
+ } +
+ } +
+
+ } +
+
+} + +@code { + [Parameter] public long GenerationId { get; set; } + [Parameter] public string ClusterId { get; set; } = string.Empty; + + private bool _loading = true; + private bool _busy; + private bool _harnessBusy; + private bool _isNew; + private List