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
60 lines
2.3 KiB
JavaScript
60 lines
2.3 KiB
JavaScript
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
|
|
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
|
|
// after attach, Monaco syncs back into the textarea on every change so Blazor's
|
|
// @bind still sees the latest value.
|
|
|
|
(function () {
|
|
if (window.otOpcUaScriptEditor) return;
|
|
|
|
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
|
|
let loaderPromise = null;
|
|
|
|
function ensureLoader() {
|
|
if (loaderPromise) return loaderPromise;
|
|
loaderPromise = new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = `${MONACO_CDN}/loader.js`;
|
|
script.onload = () => {
|
|
window.require.config({ paths: { vs: MONACO_CDN } });
|
|
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
|
|
};
|
|
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
|
|
document.head.appendChild(script);
|
|
});
|
|
return loaderPromise;
|
|
}
|
|
|
|
window.otOpcUaScriptEditor = {
|
|
attach: async function (textareaId) {
|
|
const ta = document.getElementById(textareaId);
|
|
if (!ta) return;
|
|
const monaco = await ensureLoader();
|
|
|
|
// Mount Monaco over the textarea. The textarea stays in the DOM as the
|
|
// source of truth for Blazor's @bind — Monaco mirrors into it on every
|
|
// keystroke so server-side state stays in sync.
|
|
const host = document.createElement('div');
|
|
host.style.height = '340px';
|
|
host.style.border = '1px solid #ced4da';
|
|
host.style.borderRadius = '0.25rem';
|
|
ta.style.display = 'none';
|
|
ta.parentNode.insertBefore(host, ta);
|
|
|
|
const editor = monaco.editor.create(host, {
|
|
value: ta.value,
|
|
language: 'csharp',
|
|
theme: 'vs',
|
|
automaticLayout: true,
|
|
fontSize: 13,
|
|
minimap: { enabled: false },
|
|
scrollBeyondLastLine: false,
|
|
});
|
|
|
|
editor.onDidChangeModelContent(() => {
|
|
ta.value = editor.getValue();
|
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
},
|
|
};
|
|
})();
|