Phase 7 Stream F — Admin UI for scripts + test harness + historian diagnostics

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
This commit is contained in:
Joseph Doherty
2026-04-20 19:59:18 -04:00
parent 4d4f08af0d
commit 0687bb2e2d
13 changed files with 942 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
{
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
db.ScriptedAlarms.AsNoTracking()
.Where(a => a.GenerationId == generationId)
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
.ToListAsync(ct);
public async Task<ScriptedAlarm> AddAsync(
long generationId, string equipmentId, string name, string alarmType,
int severity, string messageTemplate, string predicateScriptId,
bool historizeToAveva, bool retain, CancellationToken ct)
{
var a = new ScriptedAlarm
{
GenerationId = generationId,
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
EquipmentId = equipmentId,
Name = name,
AlarmType = alarmType,
Severity = severity,
MessageTemplate = messageTemplate,
PredicateScriptId = predicateScriptId,
HistorizeToAveva = historizeToAveva,
Retain = retain,
};
db.ScriptedAlarms.Add(a);
await db.SaveChangesAsync(ct);
return a;
}
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
{
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
if (a is null) return;
db.ScriptedAlarms.Remove(a);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
/// </summary>
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
db.ScriptedAlarmStates.AsNoTracking()
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
}