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
122 lines
5.2 KiB
C#
122 lines
5.2 KiB
C#
using Serilog; // resolves Serilog.ILogger explicitly in signatures
|
|
using Serilog.Core;
|
|
using Serilog.Events;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|
|
|
/// <summary>
|
|
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
|
|
/// map + evaluates once, returns the output (or rejection / exception) plus any
|
|
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
|
|
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
|
|
/// the harness can't prove statically surfaces as a harness error, not a runtime
|
|
/// surprise later.
|
|
/// </summary>
|
|
public sealed class ScriptTestHarnessService
|
|
{
|
|
/// <summary>
|
|
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
|
|
/// tag's new value). <paramref name="inputs"/> supplies synthetic
|
|
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
|
|
/// </summary>
|
|
public async Task<ScriptTestResult> RunVirtualTagAsync(
|
|
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
|
|
{
|
|
var deps = DependencyExtractor.Extract(source);
|
|
if (!deps.IsValid)
|
|
return ScriptTestResult.DependencyRejections(deps.Rejections);
|
|
|
|
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
|
|
if (missing.Length > 0)
|
|
return ScriptTestResult.MissingInputs(missing);
|
|
|
|
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
|
|
if (extra.Length > 0)
|
|
return ScriptTestResult.UnknownInputs(extra);
|
|
|
|
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
|
|
try
|
|
{
|
|
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
|
|
}
|
|
catch (Exception compileEx)
|
|
{
|
|
return ScriptTestResult.Threw(compileEx.Message, []);
|
|
}
|
|
var capturing = new CapturingSink();
|
|
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
|
|
var ctx = new HarnessVirtualTagContext(inputs, logger);
|
|
|
|
try
|
|
{
|
|
var result = await evaluator.RunAsync(ctx, ct);
|
|
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return ScriptTestResult.Threw(ex.Message, capturing.Events);
|
|
}
|
|
}
|
|
|
|
// Public so Roslyn's script compilation can reference the context type through the
|
|
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
|
|
public sealed class HarnessVirtualTagContext(
|
|
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
|
|
{
|
|
public Dictionary<string, object?> Writes { get; } = [];
|
|
public override DataValueSnapshot GetTag(string path) =>
|
|
inputs.TryGetValue(path, out var v)
|
|
? v
|
|
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
|
|
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
|
|
public override DateTime Now => DateTime.UtcNow;
|
|
public override Serilog.ILogger Logger => logger;
|
|
}
|
|
|
|
private sealed class CapturingSink : ILogEventSink
|
|
{
|
|
public List<LogEvent> Events { get; } = [];
|
|
public void Emit(LogEvent e) => Events.Add(e);
|
|
}
|
|
}
|
|
|
|
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
|
|
public sealed record ScriptTestResult(
|
|
ScriptTestOutcome Outcome,
|
|
object? Output,
|
|
IReadOnlyDictionary<string, object?> Writes,
|
|
IReadOnlyList<LogEvent> LogEvents,
|
|
IReadOnlyList<string> Errors)
|
|
{
|
|
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
|
|
new(ScriptTestOutcome.Success, output, writes, logs, []);
|
|
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
|
|
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
|
|
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
|
|
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
|
|
rejs.Select(r => r.Message).ToArray());
|
|
public static ScriptTestResult MissingInputs(string[] paths) =>
|
|
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
|
|
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
|
|
public static ScriptTestResult UnknownInputs(string[] paths) =>
|
|
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
|
|
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
|
|
}
|
|
|
|
public enum ScriptTestOutcome
|
|
{
|
|
Success,
|
|
Threw,
|
|
DependencyRejected,
|
|
MissingInputs,
|
|
UnknownInputs,
|
|
}
|
|
|
|
file static class Ua
|
|
{
|
|
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
|
|
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
|
|
}
|