refactor(centralui): migrate TemplateEdit's four page-embedded modals to the DialogService host (M10 residual)
TemplateEdit was the last page still hand-rolling its own modal chrome after
M10 — T34c only tokenized its backdrops. All four member-authoring forms
(Attribute, Alarm, Native Alarm Source, Script) now open through
IDialogService.ShowAsync, so the single DialogHost in MainLayout owns the
backdrop, focus trap, Escape and focus restoration.
Pattern copied from the already-migrated pages (MoveDataConnectionDialog +
Templates' Move/Rename dialogs): each body is its own component beside the page
taking a DialogContext<bool>, owning its form state, rendering validation and
server errors INLINE while staying open, and closing with Close(true) only once
the save succeeded — at which point the page reloads. An inline RenderFragment
would NOT have worked: DialogHost renders the captured fragment in its own tree,
so the page's StateHasChanged could never refresh it.
Persistence deliberately stayed on the page (it owns TemplateService, the
inherited-member rules, and the repository-direct native-source path) and is
reached through an OnSaveAsync delegate returning null on success or the message
to display. Behaviour preserved verbatim, including the List-attribute encode +
Decode round-trip check, the name/trigger-type read-only-on-edit rules, the
duplicate-native-source-name guard, and NormalizeExecutionTimeout.
TemplateScriptDialog keeps all four tab panels mounted (Monaco and the JSONJoy
island must not tear down on tab switch) and hosts the Test Run panel, which
needs the live unsaved editor buffer, so it injects ScriptAnalysisService
directly and cancels an in-flight run on dispose.
Extraction moved markup that three structural source-scanning tests pinned;
all three were repointed at the new files rather than weakened:
- TemplateNativeAlarmSourceEditorTests (+ a new test asserting the form is
host-mounted and the body renders no chrome of its own)
- AttributeListEditorTests (list-editor reveal now in the dialog body)
- TestRunWarningTests (Real I/O warning travelled with the script panel)
Build 0/0; CentralUI.Tests 973/973 green.
This commit is contained in:
@@ -47,7 +47,8 @@ Central cluster only. Sites have no user interface.
|
|||||||
- Manage template hierarchy (inheritance) — visual tree of parent/child relationships.
|
- Manage template hierarchy (inheritance) — visual tree of parent/child relationships.
|
||||||
- Manage composition — add/remove feature module instances within templates. **Naming collision detection** provides immediate feedback if composed modules introduce duplicate attribute, alarm, or script names.
|
- Manage composition — add/remove feature module instances within templates. **Naming collision detection** provides immediate feedback if composed modules introduce duplicate attribute, alarm, or script names.
|
||||||
- Define and edit attributes, alarms, and scripts on templates.
|
- Define and edit attributes, alarms, and scripts on templates.
|
||||||
- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **modal**:
|
- **Member authoring dialogs are hosted, not page-embedded** (M10 residual, 2026-08-01): the template editor's Attribute, Alarm, Native Alarm Source, and Script forms all open through `IDialogService.ShowAsync`, so the single `DialogHost` in `MainLayout` owns the backdrop, focus trap, Escape, and focus restoration. Each form body is its own component beside the page (`TemplateAttributeDialog`, `TemplateAlarmDialog`, `TemplateNativeAlarmSourceDialog`, `TemplateScriptDialog`), following the `MoveDataConnectionDialog` pattern — the body renders validation and server errors **inline and stays open**, closing only on a successful save; persistence stays on the page (which owns `TemplateService`) behind an `OnSaveAsync` delegate.
|
||||||
|
- **Native Alarms tab** (`TemplateEdit`): a tab alongside Attributes / Alarms / Scripts / Compositions that lists the template's **native alarm source bindings** — the OPC UA Alarms & Conditions / MxAccess Gateway sources whose alarm state the instance mirrors. Each binding carries Name, Connection, Source Reference, optional Condition Filter, Description, and a Lock flag. Add / edit / delete go through a **hosted dialog** (`TemplateNativeAlarmSourceDialog`):
|
||||||
- **Name** — unique within the template (lock/inherit bookkeeping mirrors `TemplateAlarm`).
|
- **Name** — unique within the template (lock/inherit bookkeeping mirrors `TemplateAlarm`).
|
||||||
- **Connection** — a dropdown filtered to **alarm-capable connections only** (OPC UA and MxGateway protocols).
|
- **Connection** — a dropdown filtered to **alarm-capable connections only** (OPC UA and MxGateway protocols).
|
||||||
- **Source Reference** — the native key (OPC UA SourceNode / notifier nodeId, or MxAccess object/area).
|
- **Source Reference** — the native key (OPC UA SourceNode / notifier nodeId, or MxAccess object/area).
|
||||||
@@ -158,7 +159,7 @@ Central cluster only. Sites have no user interface.
|
|||||||
|
|
||||||
#### Tabbed Layout
|
#### Tabbed Layout
|
||||||
|
|
||||||
The Debug View page uses a **two-tab layout** — an **Attributes** tab and an **Alarms** tab — replacing the earlier side-by-side flat tables. Each tab renders its data as a **collapsible hierarchy tree** using the existing generic `TreeView<TItem>` component.
|
The Debug View page uses a **two-tab layout** — an **Attributes** tab and an **Alarms** tab — replacing the earlier side-by-side flat tables. Each tab renders its data as a **collapsible hierarchy tree** using the existing generic `TreeView<TItem>` component. As of 2026-08-01 that component implements the full **WAI-ARIA tree keyboard pattern** — roving tabindex (one Tab stop per tree), Arrow/Home/End movement, Enter/Space activation, plus `aria-level`/`aria-posinset`/`aria-setsize` — so every tree surface in the Central UI (Debug View, Data Connections, Topology, the template folder browser) is keyboard-navigable. See [`docs/components/TreeView.md`](../components/TreeView.md).
|
||||||
|
|
||||||
**Tree hierarchy** — the hierarchy is derived from the path-qualified canonical names already present in the debug snapshot (e.g. `Motor1.Compressor.Pump`). The instance is the root node; composed modules are collapsible branch nodes; individual attributes (Attributes tab) or alarms/native-source bindings (Alarms tab) are leaf nodes.
|
**Tree hierarchy** — the hierarchy is derived from the path-qualified canonical names already present in the debug snapshot (e.g. `Motor1.Compressor.Pump`). The instance is the root node; composed modules are collapsible branch nodes; individual attributes (Attributes tab) or alarms/native-source bindings (Alarms tab) are leaf nodes.
|
||||||
|
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||||
|
|
||||||
|
@*
|
||||||
|
M10 residual: body component for the template "Add / Edit Alarm" dialog hosted
|
||||||
|
by IDialogService.ShowAsync. Renders ONLY the form fields + action buttons
|
||||||
|
inside the host's .modal-body; the host owns the backdrop, header, focus trap,
|
||||||
|
Escape, and focus restoration.
|
||||||
|
|
||||||
|
Same contract as TemplateAttributeDialog: OnSaveAsync returns null on success
|
||||||
|
(dialog closes with true) or the error to render inline (dialog stays open).
|
||||||
|
*@
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="alarm-name">Name</label>
|
||||||
|
<input id="alarm-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="alarm-trigger-type">Trigger Type</label>
|
||||||
|
<select id="alarm-trigger-type" class="form-select" @bind="_triggerType" disabled="@Editing">
|
||||||
|
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
|
||||||
|
{
|
||||||
|
<option value="@tt">@tt</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="alarm-priority">Priority</label>
|
||||||
|
<input id="alarm-priority" type="number" class="form-control" @bind="_priority" min="0" max="1000" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Trigger Configuration</label>
|
||||||
|
<AlarmTriggerEditor TriggerType="@_triggerType"
|
||||||
|
Value="@_triggerConfig"
|
||||||
|
ValueChanged="@(v => _triggerConfig = v)"
|
||||||
|
AvailableAttributes="@AvailableAttributes"
|
||||||
|
FallbackPriority="@_priority" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="alarmLocked" />
|
||||||
|
<label class="form-check-label" for="alarmLocked">Locked</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_error != null)
|
||||||
|
{
|
||||||
|
<div class="col-12"><div class="text-danger small" data-test="alarm-form-error">@_error</div></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer px-0 pb-0 mt-3">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||||
|
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
|
||||||
|
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>True when editing an existing alarm (name + trigger type become read-only).</summary>
|
||||||
|
[Parameter] public bool Editing { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||||
|
[Parameter] public int InitialPriority { get; set; }
|
||||||
|
[Parameter] public AlarmTriggerType InitialTriggerType { get; set; }
|
||||||
|
[Parameter] public string? InitialTriggerConfig { get; set; }
|
||||||
|
[Parameter] public bool InitialIsLocked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Attribute choices offered by the structured trigger editor's picker.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; }
|
||||||
|
= Array.Empty<AlarmAttributeChoice>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
|
||||||
|
/// <c>true</c>) or the message to render inline, leaving the dialog open.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired] public Func<AlarmDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
|
||||||
|
|
||||||
|
private string _name = string.Empty;
|
||||||
|
private int _priority;
|
||||||
|
private AlarmTriggerType _triggerType;
|
||||||
|
private string? _triggerConfig;
|
||||||
|
private bool _isLocked;
|
||||||
|
private string? _error;
|
||||||
|
private bool _busy;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_name = InitialName;
|
||||||
|
_priority = InitialPriority;
|
||||||
|
_triggerType = InitialTriggerType;
|
||||||
|
_triggerConfig = InitialTriggerConfig;
|
||||||
|
_isLocked = InitialIsLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Submit()
|
||||||
|
{
|
||||||
|
if (_busy) return;
|
||||||
|
_error = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var error = await OnSaveAsync(new AlarmDraft(_name, _triggerType, _priority, _triggerConfig, _isLocked));
|
||||||
|
if (error == null)
|
||||||
|
{
|
||||||
|
Context.Close(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The authored alarm as submitted; the page performs the add/update.</summary>
|
||||||
|
/// <param name="Name">Alarm name (ignored on edit — the stored name is fixed).</param>
|
||||||
|
/// <param name="TriggerType">Selected trigger type (ignored on edit — server-fixed).</param>
|
||||||
|
/// <param name="Priority">Priority level.</param>
|
||||||
|
/// <param name="TriggerConfig">Structured trigger configuration payload.</param>
|
||||||
|
/// <param name="IsLocked">Whether instances may override the alarm.</param>
|
||||||
|
public sealed record AlarmDraft(
|
||||||
|
string Name,
|
||||||
|
AlarmTriggerType TriggerType,
|
||||||
|
int Priority,
|
||||||
|
string? TriggerConfig,
|
||||||
|
bool IsLocked);
|
||||||
|
}
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||||
|
|
||||||
|
@*
|
||||||
|
M10 residual: body component for the template "Add / Edit Attribute" dialog
|
||||||
|
hosted by IDialogService.ShowAsync. Renders ONLY the form fields + action
|
||||||
|
buttons inside the host's .modal-body; the host owns the backdrop, header,
|
||||||
|
focus trap, Escape, and focus restoration.
|
||||||
|
|
||||||
|
Mirrors MoveDataConnectionDialog: the save round-trip runs from inside the
|
||||||
|
body, a failure is shown INLINE and the dialog STAYS OPEN, and success closes
|
||||||
|
with Close(true) so the page toasts and reloads. The actual persistence stays
|
||||||
|
on the page (TemplateEdit owns TemplateService + the inherited-member rules)
|
||||||
|
and is reached through the OnSaveAsync delegate, which returns null on success
|
||||||
|
or the error message to display.
|
||||||
|
*@
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="attr-name">Name</label>
|
||||||
|
<input id="attr-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="attr-data-type">Data Type</label>
|
||||||
|
<select id="attr-data-type" class="form-select" value="@_dataType" @onchange="OnDataTypeChanged" disabled="@Editing">
|
||||||
|
@foreach (var dt in Enum.GetValues<DataType>())
|
||||||
|
{
|
||||||
|
<option value="@dt">@dt</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (Editing)
|
||||||
|
{
|
||||||
|
<div class="form-text">Data type is fixed once the attribute is created.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (_dataType == DataType.List)
|
||||||
|
{
|
||||||
|
<div class="col-12">
|
||||||
|
@* List VALUE stays editable when editing; only DataType/ElementDataType are server-fixed (ShowElementType hides the type select on edit). *@
|
||||||
|
<AttributeListEditor @bind-ElementDataType="_elementDataType"
|
||||||
|
@bind-Rows="_listRows"
|
||||||
|
ShowElementType="@(!Editing)"
|
||||||
|
Disabled="false" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="attr-value">Value</label>
|
||||||
|
<input id="attr-value" type="text" class="form-control" @bind="_value" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="attr-data-source-ref">Data Source Ref</label>
|
||||||
|
<input id="attr-data-source-ref" type="text" class="form-control" @bind="_dataSourceRef" placeholder="Tag path" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="attrLocked" />
|
||||||
|
<label class="form-check-label" for="attrLocked">Locked</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_error != null)
|
||||||
|
{
|
||||||
|
<div class="col-12"><div class="text-danger small" data-test="attr-form-error">@_error</div></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer px-0 pb-0 mt-3">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||||
|
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
|
||||||
|
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>True when editing an existing attribute (name + data type become read-only).</summary>
|
||||||
|
[Parameter] public bool Editing { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string? InitialValue { get; set; }
|
||||||
|
[Parameter] public DataType InitialDataType { get; set; }
|
||||||
|
[Parameter] public DataType InitialElementDataType { get; set; } = DataType.String;
|
||||||
|
[Parameter] public IReadOnlyList<string> InitialListRows { get; set; } = Array.Empty<string>();
|
||||||
|
[Parameter] public bool InitialIsLocked { get; set; }
|
||||||
|
[Parameter] public string? InitialDataSourceRef { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
|
||||||
|
/// <c>true</c>) or the message to render inline, leaving the dialog open.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired] public Func<AttributeDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
|
||||||
|
|
||||||
|
private string _name = string.Empty;
|
||||||
|
private string? _value;
|
||||||
|
private DataType _dataType;
|
||||||
|
private DataType _elementDataType = DataType.String;
|
||||||
|
private List<string> _listRows = new();
|
||||||
|
private bool _isLocked;
|
||||||
|
private string? _dataSourceRef;
|
||||||
|
private string? _error;
|
||||||
|
private bool _busy;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_name = InitialName;
|
||||||
|
_value = InitialValue;
|
||||||
|
_dataType = InitialDataType;
|
||||||
|
_elementDataType = InitialElementDataType;
|
||||||
|
_listRows = InitialListRows.ToList();
|
||||||
|
_isLocked = InitialIsLocked;
|
||||||
|
_dataSourceRef = InitialDataSourceRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switching the data type clears stale list state so a List ⇄ scalar
|
||||||
|
// toggle never carries the other mode's value into the submit.
|
||||||
|
private void OnDataTypeChanged(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<DataType>((string?)e.Value, out var dt) || dt == _dataType) return;
|
||||||
|
_dataType = dt;
|
||||||
|
if (dt == DataType.List)
|
||||||
|
{
|
||||||
|
_value = null;
|
||||||
|
_listRows = new();
|
||||||
|
if (!AttributeValueCodec.IsValidElementType(_elementDataType))
|
||||||
|
_elementDataType = DataType.String;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_listRows = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Submit()
|
||||||
|
{
|
||||||
|
if (_busy) return;
|
||||||
|
_error = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var draft = new AttributeDraft(
|
||||||
|
_name,
|
||||||
|
_dataType,
|
||||||
|
_elementDataType,
|
||||||
|
_listRows.ToList(),
|
||||||
|
_value,
|
||||||
|
_dataSourceRef,
|
||||||
|
_isLocked);
|
||||||
|
|
||||||
|
var error = await OnSaveAsync(draft);
|
||||||
|
if (error == null)
|
||||||
|
{
|
||||||
|
Context.Close(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The authored attribute as submitted. The page resolves the stored value
|
||||||
|
/// (list encoding + validation for <see cref="DataType.List"/>) and performs
|
||||||
|
/// the add/update, so this record carries the raw form state only.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">Attribute name (ignored on edit — the stored name is fixed).</param>
|
||||||
|
/// <param name="DataType">Selected data type (fixed on edit).</param>
|
||||||
|
/// <param name="ElementDataType">Element scalar type; meaningful only for a List attribute.</param>
|
||||||
|
/// <param name="ListRows">Per-element string rows; meaningful only for a List attribute.</param>
|
||||||
|
/// <param name="Value">Scalar value; meaningful only for a non-List attribute.</param>
|
||||||
|
/// <param name="DataSourceRef">Tag path binding, or null.</param>
|
||||||
|
/// <param name="IsLocked">Whether instances may override the attribute.</param>
|
||||||
|
public sealed record AttributeDraft(
|
||||||
|
string Name,
|
||||||
|
DataType DataType,
|
||||||
|
DataType ElementDataType,
|
||||||
|
List<string> ListRows,
|
||||||
|
string? Value,
|
||||||
|
string? DataSourceRef,
|
||||||
|
bool IsLocked);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+154
@@ -0,0 +1,154 @@
|
|||||||
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
||||||
|
|
||||||
|
@*
|
||||||
|
M10 residual: body component for the template "Add / Edit Native Alarm Source"
|
||||||
|
dialog hosted by IDialogService.ShowAsync. Renders ONLY the form fields +
|
||||||
|
action buttons inside the host's .modal-body; the host owns the backdrop,
|
||||||
|
header, focus trap, Escape, and focus restoration.
|
||||||
|
|
||||||
|
Native alarm sources are a READ-ONLY mirror of OPC UA A&C / MxAccess Gateway
|
||||||
|
alarms, so the connection picker is restricted to alarm-capable protocols —
|
||||||
|
the page filters that list via AlarmCapableProtocols.IsAlarmCapable and hands
|
||||||
|
it in. OnSaveAsync returns null on success (dialog closes with true) or the
|
||||||
|
error to render inline (dialog stays open).
|
||||||
|
*@
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="nas-name">Name</label>
|
||||||
|
<input id="nas-name" type="text" class="form-control" @bind="_nasName" readonly="@Editing" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="nas-connection">Connection</label>
|
||||||
|
<select id="nas-connection" class="form-select" @bind="_nasConnection">
|
||||||
|
<option value="">— select an alarm-capable connection —</option>
|
||||||
|
@foreach (var c in AlarmCapableConnections)
|
||||||
|
{
|
||||||
|
<option value="@c.Name">@c.Name (@c.Protocol)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (AlarmCapableConnections.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="form-text text-warning">No OPC UA or MxGateway connections defined yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="nas-source-ref">Source Reference</label>
|
||||||
|
<input id="nas-source-ref" type="text" class="form-control font-monospace" @bind="_nasSourceRef"
|
||||||
|
placeholder="OPC UA SourceNode nodeId, or MxAccess object/area" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="nas-filter">Condition Filter <span class="text-muted">(optional)</span></label>
|
||||||
|
<input id="nas-filter" type="text" class="form-control" @bind="_nasFilter"
|
||||||
|
placeholder="Blank = mirror all conditions under the source" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="nas-description">Description <span class="text-muted">(optional)</span></label>
|
||||||
|
<input id="nas-description" type="text" class="form-control" @bind="_nasDescription" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" @bind="_nasIsLocked" id="nasLocked" />
|
||||||
|
<label class="form-check-label" for="nasLocked">Locked</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_error != null)
|
||||||
|
{
|
||||||
|
<div class="col-12"><div class="text-danger small" data-test="nas-form-error">@_error</div></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer px-0 pb-0 mt-3">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||||
|
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
|
||||||
|
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>True when editing an existing source (the name becomes read-only).</summary>
|
||||||
|
[Parameter] public bool Editing { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string InitialConnection { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string InitialSourceRef { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string? InitialFilter { get; set; }
|
||||||
|
[Parameter] public string? InitialDescription { get; set; }
|
||||||
|
[Parameter] public bool InitialIsLocked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Connections whose protocol can surface native alarms (OPC UA / MxGateway).</summary>
|
||||||
|
[Parameter] public IReadOnlyList<DataConnection> AlarmCapableConnections { get; set; }
|
||||||
|
= Array.Empty<DataConnection>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
|
||||||
|
/// <c>true</c>) or the message to render inline, leaving the dialog open.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired] public Func<NativeSourceDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
|
||||||
|
|
||||||
|
private string _nasName = string.Empty;
|
||||||
|
private string _nasConnection = string.Empty;
|
||||||
|
private string _nasSourceRef = string.Empty;
|
||||||
|
private string? _nasFilter;
|
||||||
|
private string? _nasDescription;
|
||||||
|
private bool _nasIsLocked;
|
||||||
|
private string? _error;
|
||||||
|
private bool _busy;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_nasName = InitialName;
|
||||||
|
_nasConnection = InitialConnection;
|
||||||
|
_nasSourceRef = InitialSourceRef;
|
||||||
|
_nasFilter = InitialFilter;
|
||||||
|
_nasDescription = InitialDescription;
|
||||||
|
_nasIsLocked = InitialIsLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Submit()
|
||||||
|
{
|
||||||
|
if (_busy) return;
|
||||||
|
_error = null;
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var draft = new NativeSourceDraft(
|
||||||
|
_nasName,
|
||||||
|
_nasConnection,
|
||||||
|
_nasSourceRef,
|
||||||
|
_nasFilter,
|
||||||
|
_nasDescription,
|
||||||
|
_nasIsLocked);
|
||||||
|
|
||||||
|
var error = await OnSaveAsync(draft);
|
||||||
|
if (error == null)
|
||||||
|
{
|
||||||
|
Context.Close(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The authored native alarm source as submitted; the page performs the add/update.</summary>
|
||||||
|
/// <param name="Name">Source name (ignored on edit — the stored name is fixed).</param>
|
||||||
|
/// <param name="ConnectionName">Alarm-capable data connection to mirror from.</param>
|
||||||
|
/// <param name="SourceReference">OPC UA SourceNode nodeId, or MxAccess object/area.</param>
|
||||||
|
/// <param name="ConditionFilter">Optional condition filter; blank mirrors every condition.</param>
|
||||||
|
/// <param name="Description">Optional free-text description.</param>
|
||||||
|
/// <param name="IsLocked">Whether instances may override the binding.</param>
|
||||||
|
public sealed record NativeSourceDraft(
|
||||||
|
string Name,
|
||||||
|
string ConnectionName,
|
||||||
|
string SourceReference,
|
||||||
|
string? ConditionFilter,
|
||||||
|
string? Description,
|
||||||
|
bool IsLocked);
|
||||||
|
}
|
||||||
+492
@@ -0,0 +1,492 @@
|
|||||||
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||||
|
@inject ScriptAnalysisService AnalysisService
|
||||||
|
|
||||||
|
@*
|
||||||
|
M10 residual: body component for the template "Add / Edit Script" dialog hosted
|
||||||
|
by IDialogService.ShowAsync. Renders ONLY the tabbed authoring surface, the
|
||||||
|
Test Run panel, and the action buttons inside the host's .modal-body; the host
|
||||||
|
owns the backdrop, header, focus trap, Escape, and focus restoration.
|
||||||
|
|
||||||
|
All four tab panels stay mounted (toggled via display:none) so the Monaco
|
||||||
|
editors and the JSONJoy React island don't tear down on tab switch — that is
|
||||||
|
why the whole form lives in ONE component rather than per-tab bodies.
|
||||||
|
|
||||||
|
The Test Run panel runs here (it needs the live, unsaved editor buffer), so
|
||||||
|
this component injects ScriptAnalysisService directly. Persistence stays on the
|
||||||
|
page and is reached through OnSaveAsync, which returns null on success (the
|
||||||
|
dialog closes with true) or the error to render inline (dialog stays open).
|
||||||
|
*@
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label" for="script-name">Name</label>
|
||||||
|
<input id="script-name" type="text" class="form-control" @bind="_name" readonly="@Editing" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" @bind="_isLocked" id="scriptLocked" />
|
||||||
|
<label class="form-check-label" for="scriptLocked">Locked</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Tabs: Trigger, Code, Parameters, Return. All panels stay
|
||||||
|
mounted (toggled via display:none) so Monaco editors and the
|
||||||
|
JSONJoy React island don't tear down on tab switch. *@
|
||||||
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button type="button"
|
||||||
|
class="nav-link @(_tab == "trigger" ? "active" : "")"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="@(_tab == "trigger" ? "true" : "false")"
|
||||||
|
@onclick='() => _tab = "trigger"'>Trigger</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button type="button"
|
||||||
|
class="nav-link @(_tab == "code" ? "active" : "")"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="@(_tab == "code" ? "true" : "false")"
|
||||||
|
@onclick='() => _tab = "code"'>Code</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button type="button"
|
||||||
|
class="nav-link @(_tab == "parameters" ? "active" : "")"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="@(_tab == "parameters" ? "true" : "false")"
|
||||||
|
@onclick='() => _tab = "parameters"'>Parameters</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button type="button"
|
||||||
|
class="nav-link @(_tab == "return" ? "active" : "")"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="@(_tab == "return" ? "true" : "false")"
|
||||||
|
@onclick='() => _tab = "return"'>Return type</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="border border-top-0 rounded-bottom p-3">
|
||||||
|
<div style="display: @(_tab == "trigger" ? "block" : "none")">
|
||||||
|
<ScriptTriggerEditor TriggerType="@_triggerType"
|
||||||
|
TriggerConfig="@_triggerConfig"
|
||||||
|
Changed="@OnTriggerChanged"
|
||||||
|
AvailableAttributes="@AvailableAttributes" />
|
||||||
|
@if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_triggerType))
|
||||||
|
{
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Min time between runs</label>
|
||||||
|
<div class="row g-2" style="max-width: 420px;">
|
||||||
|
<div class="col-7">
|
||||||
|
<input type="number" min="1" step="1" class="form-control"
|
||||||
|
placeholder="(optional)"
|
||||||
|
@bind="_minTimeValue" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<select class="form-select" @bind="_minTimeUnit">
|
||||||
|
<option value="ms">milliseconds</option>
|
||||||
|
<option value="sec">seconds</option>
|
||||||
|
<option value="min">minutes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (TriggerIsWhileTrue())
|
||||||
|
{
|
||||||
|
<div class="form-text">
|
||||||
|
This is the re-fire interval for the
|
||||||
|
<strong>WhileTrue</strong> trigger above.
|
||||||
|
</div>
|
||||||
|
@if (DurationInput.Compose(_minTimeValue, _minTimeUnit) is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning py-1 px-2 small mt-1 mb-0">
|
||||||
|
The WhileTrue trigger has no interval set — the script
|
||||||
|
will fire only once. Set a value here to make it re-fire.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="form-text">
|
||||||
|
Optional throttle — skips trigger invocations that fire
|
||||||
|
sooner than this.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Execution timeout</label>
|
||||||
|
<div class="input-group" style="max-width: 280px;">
|
||||||
|
<input type="number" min="1" step="1" class="form-control"
|
||||||
|
placeholder="(site default)"
|
||||||
|
@bind="_executionTimeoutSeconds" @bind:event="oninput" />
|
||||||
|
<span class="input-group-text">seconds</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
Per-script execution timeout. Leave blank (or 0) to use the
|
||||||
|
site's global default.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: @(_tab == "code" ? "block" : "none")">
|
||||||
|
<MonacoEditor @ref="_editor" Value="@_code" ValueChanged="@(v => _code = v)"
|
||||||
|
Language="csharp" Height="360px"
|
||||||
|
DeclaredParameters="@ScriptParameterNames.Parse(_parameters)"
|
||||||
|
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_parameters)"
|
||||||
|
SiblingScripts="@SiblingScripts"
|
||||||
|
SelfAttributes="@SelfAttributes"
|
||||||
|
Children="@EditorChildren"
|
||||||
|
Parent="@EditorParent"
|
||||||
|
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||||
|
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||||
|
</div>
|
||||||
|
<div style="display: @(_tab == "parameters" ? "block" : "none")">
|
||||||
|
<SchemaBuilder Mode="object"
|
||||||
|
Value="@_parameters"
|
||||||
|
ValueChanged="@(v => _parameters = v)" />
|
||||||
|
</div>
|
||||||
|
<div style="display: @(_tab == "return" ? "block" : "none")">
|
||||||
|
<SchemaBuilder Mode="value"
|
||||||
|
Value="@_return"
|
||||||
|
ValueChanged="@(v => _return = v)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_error != null)
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-2" data-test="script-form-error">@_error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showTestRun)
|
||||||
|
{
|
||||||
|
<div class="card mt-3" id="script-test-run-panel">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||||
|
<strong>Heads up:</strong>
|
||||||
|
runs the script as typed (unsaved edits included) against the supplied
|
||||||
|
<code>Parameters</code>.
|
||||||
|
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
||||||
|
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
||||||
|
<code>Instance</code>, <code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and <code>CallScript</code> throw unless a bound instance is selected below — then they route to that live instance (attribute writes are permanent too).
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Bind to instance <span class="text-muted">(optional)</span></label>
|
||||||
|
@if (DeployedInstances.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="form-text">
|
||||||
|
No running instances of this template.
|
||||||
|
<code>Instance</code>/<code>Attributes</code>/<code>CallScript</code> will throw.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm" @bind="_bindInstance">
|
||||||
|
<option value="">— None (Instance/Attributes throw) —</option>
|
||||||
|
@foreach (var inst in DeployedInstances)
|
||||||
|
{
|
||||||
|
<option value="@inst.UniqueName">@inst.UniqueName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Routes <code>Instance.GetAttribute/SetAttribute</code>,
|
||||||
|
<code>Attributes</code>, <code>Children</code>, <code>Parent</code>, and
|
||||||
|
<code>CallScript</code> to the selected live instance.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Parameter values</label>
|
||||||
|
<ParameterValueForm ParameterDefinitions="@_parameters"
|
||||||
|
Values="_paramValues"
|
||||||
|
ValuesChanged="@(v => _paramValues = v)" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center mb-3">
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||||
|
@if (_running)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
<span>Running…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Run</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (_runResult != null)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_runResult != null)
|
||||||
|
{
|
||||||
|
@if (_runResult.Success)
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-success mb-1">
|
||||||
|
Return value <span class="badge bg-secondary-subtle text-secondary-emphasis ms-1">@_runResult.ReturnTypeName</span>
|
||||||
|
</label>
|
||||||
|
<pre class="bg-body-secondary border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-danger mb-1">
|
||||||
|
<span class="badge bg-danger me-1">@ScriptErrorKindLabel(_runResult.ErrorKind)</span>
|
||||||
|
</label>
|
||||||
|
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||||
|
@if (_runResult.Markers is { Count: > 0 })
|
||||||
|
{
|
||||||
|
<ul class="small text-danger mt-2 mb-0">
|
||||||
|
@foreach (var m in _runResult.Markers)
|
||||||
|
{
|
||||||
|
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||||
|
{
|
||||||
|
<div class="mb-0">
|
||||||
|
<label class="form-label small mb-1">Console output</label>
|
||||||
|
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="modal-footer px-0 pb-0 mt-3">
|
||||||
|
<button class="btn btn-outline-primary btn-sm me-auto" @onclick="ToggleTestRunPanel">
|
||||||
|
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="Cancel" disabled="@_busy">Cancel</button>
|
||||||
|
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Host-supplied context: Close(true) on a successful save, Cancel on dismiss.</summary>
|
||||||
|
[Parameter, EditorRequired] public DialogContext<bool> Context { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>True when editing an existing script (the name becomes read-only).</summary>
|
||||||
|
[Parameter] public bool Editing { get; set; }
|
||||||
|
|
||||||
|
[Parameter] public string InitialName { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string InitialCode { get; set; } = string.Empty;
|
||||||
|
[Parameter] public string? InitialTriggerType { get; set; }
|
||||||
|
[Parameter] public string? InitialTriggerConfig { get; set; }
|
||||||
|
[Parameter] public TimeSpan? InitialMinTimeBetweenRuns { get; set; }
|
||||||
|
[Parameter] public string? InitialParameters { get; set; }
|
||||||
|
[Parameter] public string? InitialReturn { get; set; }
|
||||||
|
[Parameter] public bool InitialIsLocked { get; set; }
|
||||||
|
[Parameter] public int? InitialExecutionTimeoutSeconds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Attribute choices offered by the structured trigger editor's picker.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<AlarmAttributeChoice> AvailableAttributes { get; set; }
|
||||||
|
= Array.Empty<AlarmAttributeChoice>();
|
||||||
|
|
||||||
|
/// <summary>Sibling scripts on the same template, for <c>CallScript</c> completion.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<ScriptShape>? SiblingScripts { get; set; }
|
||||||
|
|
||||||
|
/// <summary>This template's own attributes, for <c>Attributes.*</c> completion.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<AttributeShape>? SelfAttributes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Composed children's contexts, for <c>Children.*</c> completion.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<CompositionContext>? EditorChildren { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The slot-owner context, for <c>Parent.*</c> completion; null for a base template.</summary>
|
||||||
|
[Parameter] public CompositionContext? EditorParent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Running instances of this template, offered as Test Run bind targets.</summary>
|
||||||
|
[Parameter] public IReadOnlyList<ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance> DeployedInstances { get; set; }
|
||||||
|
= Array.Empty<ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the draft. Returns <c>null</c> on success (the dialog closes with
|
||||||
|
/// <c>true</c>) or the message to render inline, leaving the dialog open.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired] public Func<ScriptDraft, Task<string?>> OnSaveAsync { get; set; } = default!;
|
||||||
|
|
||||||
|
private string _name = string.Empty;
|
||||||
|
private string _code = string.Empty;
|
||||||
|
private string? _triggerType;
|
||||||
|
private string? _triggerConfig;
|
||||||
|
private string? _minTimeValue;
|
||||||
|
private string _minTimeUnit = "sec";
|
||||||
|
private string? _parameters;
|
||||||
|
private string? _return;
|
||||||
|
private bool _isLocked;
|
||||||
|
private int? _executionTimeoutSeconds;
|
||||||
|
private string? _error;
|
||||||
|
private bool _busy;
|
||||||
|
private string _tab = "trigger"; // "trigger" | "code" | "parameters" | "return"
|
||||||
|
private MonacoEditor? _editor;
|
||||||
|
private IReadOnlyList<DiagnosticMarker> _markers = Array.Empty<DiagnosticMarker>();
|
||||||
|
|
||||||
|
// Test Run state.
|
||||||
|
private bool _showTestRun;
|
||||||
|
private bool _running;
|
||||||
|
private Dictionary<string, object?> _paramValues = new();
|
||||||
|
private SandboxRunResult? _runResult;
|
||||||
|
private CancellationTokenSource? _runCts;
|
||||||
|
private string _bindInstance = string.Empty;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_name = InitialName;
|
||||||
|
_code = InitialCode;
|
||||||
|
_triggerType = InitialTriggerType;
|
||||||
|
_triggerConfig = InitialTriggerConfig;
|
||||||
|
(_minTimeValue, _minTimeUnit) = DurationInput.Split(InitialMinTimeBetweenRuns);
|
||||||
|
_parameters = InitialParameters;
|
||||||
|
_return = InitialReturn;
|
||||||
|
_isLocked = InitialIsLocked;
|
||||||
|
_executionTimeoutSeconds = InitialExecutionTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cancels an in-flight Test Run when the dialog is torn down.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_runCts?.Cancel();
|
||||||
|
_runCts?.Dispose();
|
||||||
|
_runCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies the structured trigger editor's type + config atomically.</summary>
|
||||||
|
private void OnTriggerChanged(ScriptTriggerValue v)
|
||||||
|
{
|
||||||
|
_triggerType = v.TriggerType;
|
||||||
|
_triggerConfig = v.Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the current script trigger is a WhileTrue Conditional/Expression
|
||||||
|
/// trigger — the case where the "Min time between runs" interval is required
|
||||||
|
/// (it is the re-fire cadence).
|
||||||
|
/// </summary>
|
||||||
|
private bool TriggerIsWhileTrue()
|
||||||
|
{
|
||||||
|
var kind = ScriptTriggerConfigCodec.ParseKind(_triggerType);
|
||||||
|
return kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression
|
||||||
|
&& ScriptTriggerConfigCodec.Parse(_triggerConfig, kind).Mode
|
||||||
|
== ScriptTriggerMode.WhileTrue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
Context.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunInSandboxAsync()
|
||||||
|
{
|
||||||
|
_runCts?.Cancel();
|
||||||
|
_runCts = new CancellationTokenSource();
|
||||||
|
_running = true;
|
||||||
|
_runResult = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jsonParams = _paramValues.ToDictionary(
|
||||||
|
kv => kv.Key,
|
||||||
|
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
||||||
|
var request = new SandboxRunRequest(
|
||||||
|
_code, jsonParams, TimeoutSeconds: null,
|
||||||
|
BindInstanceUniqueName: string.IsNullOrEmpty(_bindInstance) ? null : _bindInstance);
|
||||||
|
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_runResult = new SandboxRunResult(
|
||||||
|
Success: false,
|
||||||
|
ReturnValueJson: null,
|
||||||
|
ReturnTypeName: null,
|
||||||
|
ConsoleOutput: "",
|
||||||
|
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
||||||
|
ErrorKind: SandboxErrorKind.RuntimeError,
|
||||||
|
DurationMs: 0,
|
||||||
|
Markers: null);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ScriptErrorKindLabel(SandboxErrorKind kind) => kind switch
|
||||||
|
{
|
||||||
|
SandboxErrorKind.CompileError => "Compile error",
|
||||||
|
SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
||||||
|
SandboxErrorKind.RuntimeError => "Runtime error",
|
||||||
|
SandboxErrorKind.Timeout => "Timeout",
|
||||||
|
_ => "Error"
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task Submit()
|
||||||
|
{
|
||||||
|
if (_busy) return;
|
||||||
|
_error = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_name)) { _error = "Name is required."; return; }
|
||||||
|
if (string.IsNullOrWhiteSpace(_code)) { _error = "Code is required."; return; }
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var draft = new ScriptDraft(
|
||||||
|
_name,
|
||||||
|
_code,
|
||||||
|
_triggerType,
|
||||||
|
_triggerConfig,
|
||||||
|
DurationInput.Compose(_minTimeValue, _minTimeUnit),
|
||||||
|
_parameters,
|
||||||
|
_return,
|
||||||
|
_isLocked,
|
||||||
|
_executionTimeoutSeconds);
|
||||||
|
|
||||||
|
var error = await OnSaveAsync(draft);
|
||||||
|
if (error == null)
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
Context.Close(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The authored script as submitted; the page performs the add/update.</summary>
|
||||||
|
/// <param name="Name">Script name (ignored on edit — the stored name is fixed).</param>
|
||||||
|
/// <param name="Code">Script body as typed.</param>
|
||||||
|
/// <param name="TriggerType">Structured trigger type token, or null.</param>
|
||||||
|
/// <param name="TriggerConfig">Structured trigger configuration payload, or null.</param>
|
||||||
|
/// <param name="MinTimeBetweenRuns">Composed throttle / re-fire interval, or null.</param>
|
||||||
|
/// <param name="ParameterDefinitions">Parameter schema JSON, or null.</param>
|
||||||
|
/// <param name="ReturnDefinition">Return-type schema JSON, or null.</param>
|
||||||
|
/// <param name="IsLocked">Whether instances may override the script.</param>
|
||||||
|
/// <param name="ExecutionTimeoutSeconds">Raw timeout input; the page normalizes ≤0 to null.</param>
|
||||||
|
public sealed record ScriptDraft(
|
||||||
|
string Name,
|
||||||
|
string Code,
|
||||||
|
string? TriggerType,
|
||||||
|
string? TriggerConfig,
|
||||||
|
TimeSpan? MinTimeBetweenRuns,
|
||||||
|
string? ParameterDefinitions,
|
||||||
|
string? ReturnDefinition,
|
||||||
|
bool IsLocked,
|
||||||
|
int? ExecutionTimeoutSeconds);
|
||||||
|
}
|
||||||
@@ -14,18 +14,27 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AttributeListEditorTests : BunitContext
|
public class AttributeListEditorTests : BunitContext
|
||||||
{
|
{
|
||||||
private static string TemplateEditMarkup
|
private static string DesignComponentsRoot
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
var dir = AppContext.BaseDirectory;
|
var dir = AppContext.BaseDirectory;
|
||||||
for (var i = 0; i < 6 && dir is not null; i++)
|
for (var i = 0; i < 6 && dir is not null; i++)
|
||||||
dir = Directory.GetParent(dir)?.FullName;
|
dir = Directory.GetParent(dir)?.FullName;
|
||||||
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
||||||
"Components", "Pages", "Design", "TemplateEdit.razor"));
|
"Components", "Pages", "Design");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string TemplateEditMarkup
|
||||||
|
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor"));
|
||||||
|
|
||||||
|
// M10 residual: the attribute authoring form moved out of the page into the
|
||||||
|
// dialog body component hosted by IDialogService.ShowAsync. Submit-side
|
||||||
|
// encoding stays on the page (it owns TemplateService).
|
||||||
|
private static string AttributeDialogMarkup
|
||||||
|
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateAttributeDialog.razor"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Editor_RendersElementTypeSelect_WithSixValidScalars()
|
public void Editor_RendersElementTypeSelect_WithSixValidScalars()
|
||||||
{
|
{
|
||||||
@@ -117,16 +126,18 @@ public class AttributeListEditorTests : BunitContext
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateEdit_RevealsListEditor_AndSendsElementType()
|
public void TemplateEdit_RevealsListEditor_AndSendsElementType()
|
||||||
{
|
{
|
||||||
var markup = TemplateEditMarkup;
|
var dialogMarkup = AttributeDialogMarkup;
|
||||||
// Conditional reveal on DataType.List.
|
// Conditional reveal on DataType.List.
|
||||||
Assert.Contains("_attrDataType == DataType.List", markup);
|
Assert.Contains("_dataType == DataType.List", dialogMarkup);
|
||||||
Assert.Contains("<AttributeListEditor", markup);
|
Assert.Contains("<AttributeListEditor", dialogMarkup);
|
||||||
Assert.Contains("@bind-ElementDataType=\"_attrElementDataType\"", markup);
|
Assert.Contains("@bind-ElementDataType=\"_elementDataType\"", dialogMarkup);
|
||||||
Assert.Contains("@bind-Rows=\"_attrListRows\"", markup);
|
Assert.Contains("@bind-Rows=\"_listRows\"", dialogMarkup);
|
||||||
|
|
||||||
|
var pageMarkup = TemplateEditMarkup;
|
||||||
// Submit encodes rows to canonical JSON and passes the element type.
|
// Submit encodes rows to canonical JSON and passes the element type.
|
||||||
Assert.Contains("AttributeValueCodec.Encode(_attrListRows)", markup);
|
Assert.Contains("AttributeValueCodec.Encode(draft.ListRows)", pageMarkup);
|
||||||
Assert.Contains("ElementDataType = elementType", markup);
|
Assert.Contains("ElementDataType = elementType", pageMarkup);
|
||||||
// Edit decodes the stored JSON value into rows.
|
// Edit decodes the stored JSON value into rows.
|
||||||
Assert.Contains("DecodeListRows(", markup);
|
Assert.Contains("DecodeListRows(", pageMarkup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-15
@@ -13,18 +13,28 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class TemplateNativeAlarmSourceEditorTests
|
public class TemplateNativeAlarmSourceEditorTests
|
||||||
{
|
{
|
||||||
private static string TemplateEditMarkup
|
private static string DesignComponentsRoot
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
var dir = AppContext.BaseDirectory;
|
var dir = AppContext.BaseDirectory;
|
||||||
for (var i = 0; i < 6 && dir is not null; i++)
|
for (var i = 0; i < 6 && dir is not null; i++)
|
||||||
dir = Directory.GetParent(dir)?.FullName;
|
dir = Directory.GetParent(dir)?.FullName;
|
||||||
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
return Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
|
||||||
"Components", "Pages", "Design", "TemplateEdit.razor"));
|
"Components", "Pages", "Design");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string TemplateEditMarkup
|
||||||
|
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateEdit.razor"));
|
||||||
|
|
||||||
|
// M10 residual: the authoring form moved out of the page into the dialog body
|
||||||
|
// component hosted by IDialogService.ShowAsync. The field-level assertions
|
||||||
|
// below follow it there; the tab, the connection filtering, and the CRUD
|
||||||
|
// wiring all still live on the page.
|
||||||
|
private static string NativeSourceDialogMarkup
|
||||||
|
=> File.ReadAllText(Path.Combine(DesignComponentsRoot, "TemplateNativeAlarmSourceDialog.razor"));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TemplateEditor_HasNativeAlarmsTab()
|
public void TemplateEditor_HasNativeAlarmsTab()
|
||||||
{
|
{
|
||||||
@@ -38,19 +48,39 @@ public class TemplateNativeAlarmSourceEditorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void NativeAlarmsForm_HasConnectionSourceFilterAndLockFields()
|
public void NativeAlarmsForm_HasConnectionSourceFilterAndLockFields()
|
||||||
{
|
{
|
||||||
var markup = TemplateEditMarkup;
|
var pageMarkup = TemplateEditMarkup;
|
||||||
// Connection dropdown filtered to alarm-capable protocols via the
|
// Connection dropdown filtered to alarm-capable protocols via the
|
||||||
// single-source-of-truth Commons helper. The OpcUa/MxGateway literal set
|
// single-source-of-truth Commons helper. The OpcUa/MxGateway literal set
|
||||||
// now lives in AlarmCapableProtocols (Commons) — pinned by
|
// now lives in AlarmCapableProtocols (Commons) — pinned by
|
||||||
// AlarmCapableProtocolsTests — so this page only needs to delegate to it.
|
// AlarmCapableProtocolsTests — so this page only needs to delegate to it.
|
||||||
Assert.Contains("_alarmCapableConnections", markup);
|
Assert.Contains("_alarmCapableConnections", pageMarkup);
|
||||||
Assert.Contains("AlarmCapableProtocols.IsAlarmCapable", markup);
|
Assert.Contains("AlarmCapableProtocols.IsAlarmCapable", pageMarkup);
|
||||||
// The authoring form fields.
|
// …and hands the filtered list to the dialog body.
|
||||||
Assert.Contains("@bind=\"_nasName\"", markup);
|
Assert.Contains("AlarmCapableConnections=", pageMarkup);
|
||||||
Assert.Contains("@bind=\"_nasConnection\"", markup);
|
|
||||||
Assert.Contains("@bind=\"_nasSourceRef\"", markup);
|
// The authoring form fields now live in the dialog body component.
|
||||||
Assert.Contains("@bind=\"_nasFilter\"", markup);
|
var dialogMarkup = NativeSourceDialogMarkup;
|
||||||
Assert.Contains("@bind=\"_nasIsLocked\"", markup);
|
Assert.Contains("@bind=\"_nasName\"", dialogMarkup);
|
||||||
|
Assert.Contains("@bind=\"_nasConnection\"", dialogMarkup);
|
||||||
|
Assert.Contains("@bind=\"_nasSourceRef\"", dialogMarkup);
|
||||||
|
Assert.Contains("@bind=\"_nasFilter\"", dialogMarkup);
|
||||||
|
Assert.Contains("@bind=\"_nasIsLocked\"", dialogMarkup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NativeAlarmsForm_IsHostedByTheDialogService()
|
||||||
|
{
|
||||||
|
// M10 residual: the page-embedded modal is gone — the form opens through
|
||||||
|
// IDialogService.ShowAsync so the host owns the backdrop, focus trap,
|
||||||
|
// Escape, and focus restoration.
|
||||||
|
var pageMarkup = TemplateEditMarkup;
|
||||||
|
Assert.Contains("<TemplateNativeAlarmSourceDialog", pageMarkup);
|
||||||
|
Assert.DoesNotContain("_showNativeSourceForm", pageMarkup);
|
||||||
|
|
||||||
|
// The body renders no chrome of its own (no backdrop / modal shell).
|
||||||
|
var dialogMarkup = NativeSourceDialogMarkup;
|
||||||
|
Assert.DoesNotContain("sb-modal-backdrop", dialogMarkup);
|
||||||
|
Assert.Contains("DialogContext<bool> Context", dialogMarkup);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -63,9 +93,9 @@ public class TemplateNativeAlarmSourceEditorTests
|
|||||||
Assert.Contains("GetNativeAlarmSourcesByTemplateIdAsync", markup);
|
Assert.Contains("GetNativeAlarmSourcesByTemplateIdAsync", markup);
|
||||||
Assert.Contains("SaveChangesAsync", markup);
|
Assert.Contains("SaveChangesAsync", markup);
|
||||||
// Add/edit/delete handlers are wired to the UI.
|
// Add/edit/delete handlers are wired to the UI.
|
||||||
Assert.Contains("BeginAddNativeSource", markup);
|
Assert.Contains("OpenAddNativeSourceDialog", markup);
|
||||||
Assert.Contains("BeginEditNativeSource", markup);
|
Assert.Contains("OpenEditNativeSourceDialog", markup);
|
||||||
Assert.Contains("SaveNativeSource", markup);
|
Assert.Contains("SaveNativeSourceDraftAsync", markup);
|
||||||
Assert.Contains("DeleteNativeSource", markup);
|
Assert.Contains("DeleteNativeSource", markup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ public class TestRunWarningTests
|
|||||||
private static string Read(string fileName)
|
private static string Read(string fileName)
|
||||||
=> File.ReadAllText(Path.Combine(SrcRoot, fileName));
|
=> File.ReadAllText(Path.Combine(SrcRoot, fileName));
|
||||||
|
|
||||||
|
// M10 residual: the template Test Run panel moved out of TemplateEdit.razor
|
||||||
|
// into TemplateScriptDialog.razor when the page-embedded script modal was
|
||||||
|
// migrated to the IDialogService host. The warning travelled with it.
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("SharedScriptForm.razor")]
|
[InlineData("SharedScriptForm.razor")]
|
||||||
[InlineData("TemplateEdit.razor")]
|
[InlineData("TemplateScriptDialog.razor")]
|
||||||
public void TestRunPanel_WithRealIoSurface_ShowsRealIoBadgeAndWarning(string razorFile)
|
public void TestRunPanel_WithRealIoSurface_ShowsRealIoBadgeAndWarning(string razorFile)
|
||||||
{
|
{
|
||||||
var markup = Read(razorFile);
|
var markup = Read(razorFile);
|
||||||
|
|||||||
Reference in New Issue
Block a user