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
225 lines
9.4 KiB
Plaintext
225 lines
9.4 KiB
Plaintext
@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
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h4 class="mb-0">Scripts</h4>
|
|
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
|
|
</div>
|
|
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
|
|
</div>
|
|
|
|
<script src="/js/monaco-loader.js"></script>
|
|
|
|
@if (_loading) { <p class="text-muted">Loading…</p> }
|
|
else if (_scripts.Count == 0 && _editing is null)
|
|
{
|
|
<div class="alert alert-info">No scripts yet in this draft.</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="row">
|
|
<div class="col-md-4">
|
|
<div class="list-group">
|
|
@foreach (var s in _scripts)
|
|
{
|
|
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
|
|
@onclick="() => Open(s)">
|
|
<strong>@s.Name</strong>
|
|
<div class="small text-muted font-monospace">@s.ScriptId</div>
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="col-md-8">
|
|
@if (_editing is not null)
|
|
{
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
|
|
<div>
|
|
@if (!_isNew)
|
|
{
|
|
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
|
|
}
|
|
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-2">
|
|
<label class="form-label">Name</label>
|
|
<input class="form-control" @bind="_editing.Name"/>
|
|
</div>
|
|
<label class="form-label">Source</label>
|
|
<ScriptEditor @bind-Source="_editing.SourceCode"/>
|
|
|
|
<div class="mt-3">
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
|
|
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
|
|
</div>
|
|
|
|
@if (_dependencies is not null)
|
|
{
|
|
<div class="mt-3">
|
|
<strong>Inferred reads</strong>
|
|
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
|
|
else
|
|
{
|
|
<ul class="mb-1">
|
|
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
|
|
</ul>
|
|
}
|
|
<strong>Inferred writes</strong>
|
|
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
|
|
else
|
|
{
|
|
<ul class="mb-1">
|
|
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
|
|
</ul>
|
|
}
|
|
@if (_dependencies.Rejections.Count > 0)
|
|
{
|
|
<div class="alert alert-danger mt-2">
|
|
<strong>Non-literal paths rejected:</strong>
|
|
<ul class="mb-0">
|
|
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
|
|
</ul>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (_testResult is not null)
|
|
{
|
|
<div class="mt-3 border-top pt-3">
|
|
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
|
|
@if (_testResult.Outcome == ScriptTestOutcome.Success)
|
|
{
|
|
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
|
|
@if (_testResult.Writes.Count > 0)
|
|
{
|
|
<div class="mt-1"><strong>Writes:</strong>
|
|
<ul class="mb-0">
|
|
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
|
|
</ul>
|
|
</div>
|
|
}
|
|
}
|
|
@if (_testResult.Errors.Count > 0)
|
|
{
|
|
<div class="alert alert-warning mt-2 mb-0">
|
|
@foreach (var e in _testResult.Errors) { <div>@e</div> }
|
|
</div>
|
|
}
|
|
@if (_testResult.LogEvents.Count > 0)
|
|
{
|
|
<div class="mt-2"><strong>Script log output:</strong>
|
|
<ul class="small mb-0">
|
|
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
|
|
</ul>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@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<Script> _scripts = [];
|
|
private Script? _editing;
|
|
private DependencyExtractionResult? _dependencies;
|
|
private ScriptTestResult? _testResult;
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
_loading = true;
|
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
|
_loading = false;
|
|
}
|
|
|
|
private void Open(Script s)
|
|
{
|
|
_editing = new Script
|
|
{
|
|
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
|
|
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
|
|
SourceHash = s.SourceHash, Language = s.Language,
|
|
};
|
|
_isNew = false;
|
|
_dependencies = null;
|
|
_testResult = null;
|
|
}
|
|
|
|
private void StartNew()
|
|
{
|
|
_editing = new Script
|
|
{
|
|
GenerationId = GenerationId, ScriptId = "",
|
|
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
|
|
};
|
|
_isNew = true;
|
|
_dependencies = null;
|
|
_testResult = null;
|
|
}
|
|
|
|
private async Task SaveAsync()
|
|
{
|
|
if (_editing is null) return;
|
|
_busy = true;
|
|
try
|
|
{
|
|
if (_isNew)
|
|
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
|
else
|
|
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
|
_isNew = false;
|
|
}
|
|
finally { _busy = false; }
|
|
}
|
|
|
|
private async Task DeleteAsync()
|
|
{
|
|
if (_editing is null || _isNew) return;
|
|
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
|
|
_editing = null;
|
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
|
}
|
|
|
|
private void PreviewDependencies()
|
|
{
|
|
if (_editing is null) return;
|
|
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
|
|
}
|
|
|
|
private async Task RunHarnessAsync()
|
|
{
|
|
if (_editing is null) return;
|
|
_harnessBusy = true;
|
|
try
|
|
{
|
|
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
|
|
var inputs = new Dictionary<string, DataValueSnapshot>();
|
|
foreach (var read in _dependencies.Reads)
|
|
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
|
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
|
|
}
|
|
finally { _harnessBusy = false; }
|
|
}
|
|
}
|