feat(ui): template editor Native Alarm Sources subsection

This commit is contained in:
Joseph Doherty
2026-05-31 02:40:52 -04:00
parent 1f6c4207df
commit 60f8e2c9a7
2 changed files with 330 additions and 0 deletions
@@ -2,6 +2,7 @@
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine
@@ -92,6 +93,19 @@
private bool _alarmIsLocked;
private string? _alarmFormError;
// Native alarm source bindings (read-only mirror of OPC UA A&C / MxGateway alarms)
private List<TemplateNativeAlarmSource> _nativeSources = new();
private List<DataConnection> _alarmCapableConnections = new();
private bool _showNativeSourceForm;
private int? _editNativeSourceId;
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? _nasFormError;
private bool _showScriptForm;
private int? _editScriptId;
private string _scriptName = string.Empty;
@@ -156,6 +170,13 @@
_attributes = (await TemplateEngineRepository.GetAttributesByTemplateIdAsync(Id)).ToList();
_alarms = (await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(Id)).ToList();
_nativeSources = (await TemplateEngineRepository.GetNativeAlarmSourcesByTemplateIdAsync(Id)).ToList();
_alarmCapableConnections = (await CentralUiRepository.GetAllDataConnectionsAsync())
.Where(c => IsAlarmCapable(c.Protocol))
.GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(Id)).ToList();
@@ -352,6 +373,15 @@
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "native-alarms" ? "active" : "")"
role="tab"
aria-selected="@(_activeTab == "native-alarms" ? "true" : "false")"
aria-controls="tmpl-tab-native-alarms"
@onclick='() => _activeTab = "native-alarms"'>
Native Alarms <span class="badge bg-secondary">@_nativeSources.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")"
role="tab"
@@ -367,6 +397,10 @@
{
<div role="tabpanel" id="tmpl-tab-attributes">@RenderAttributesTab()</div>
}
else if (_activeTab == "native-alarms")
{
<div role="tabpanel" id="tmpl-tab-native-alarms">@RenderNativeAlarmsTab()</div>
}
else if (_activeTab == "alarms")
{
<div role="tabpanel" id="tmpl-tab-alarms">@RenderAlarmsTab()</div>
@@ -852,6 +886,232 @@
</table>
};
// ---- Native Alarms Tab ----
private RenderFragment RenderNativeAlarmsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0">Native Alarm Sources</h5>
<button class="btn btn-primary btn-sm" @onclick="BeginAddNativeSource">Add Source</button>
</div>
<p class="text-muted small">
Read-only mirror of alarms from an OPC UA Alarms &amp; Conditions server or the
MxAccess Gateway. Discovered at runtime and shown live in the Debug View — no ack-back.
</p>
@if (_showNativeSourceForm)
{
var editing = _editNativeSourceId.HasValue;
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">@(editing ? "Edit Native Alarm Source" : "Add Native Alarm Source")</h6>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelNativeSourceForm"></button>
</div>
<div class="modal-body">
<div class="row g-2">
<div class="col-12">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_nasName" readonly="@editing" />
</div>
<div class="col-12">
<label class="form-label">Connection</label>
<select 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">Source Reference</label>
<input 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">Condition Filter <span class="text-muted">(optional)</span></label>
<input type="text" class="form-control" @bind="_nasFilter"
placeholder="Blank = mirror all conditions under the source" />
</div>
<div class="col-12">
<label class="form-label">Description <span class="text-muted">(optional)</span></label>
<input 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 (_nasFormError != null)
{
<div class="col-12"><div class="text-danger small">@_nasFormError</div></div>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelNativeSourceForm">Cancel</button>
<button class="btn btn-success btn-sm" @onclick="SaveNativeSource">@(editing ? "Save" : "Add")</button>
</div>
</div>
</div>
</div>
}
@if (_nativeSources.Count == 0)
{
<p class="text-muted">No native alarm sources defined.</p>
}
else
{
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Connection</th>
<th>Source Reference</th>
<th>Filter</th>
<th>Lock</th>
<th style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var src in _nativeSources)
{
<tr>
<td>@src.Name</td>
<td><span class="badge bg-light text-dark">@src.ConnectionName</span></td>
<td class="small font-monospace text-truncate" style="max-width: 220px;" title="@src.SourceReference">@src.SourceReference</td>
<td class="small text-muted">@(string.IsNullOrEmpty(src.ConditionFilter) ? "—" : src.ConditionFilter)</td>
<td>
@if (src.IsLocked)
{
<span class="badge bg-danger" aria-label="Locked">Locked</span>
}
else
{
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
data-bs-toggle="dropdown" aria-expanded="false"
aria-label="@($"More actions for {src.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item" @onclick="() => BeginEditNativeSource(src)">Edit…</button></li>
<li><hr class="dropdown-divider" /></li>
<li><button class="dropdown-item text-danger" @onclick="() => DeleteNativeSource(src)">Delete</button></li>
</ul>
</div>
</td>
</tr>
}
</tbody>
</table>
}
};
private static bool IsAlarmCapable(string? protocol) =>
string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
private void BeginAddNativeSource()
{
_showNativeSourceForm = true;
_editNativeSourceId = null;
_nasFormError = null;
_nasName = string.Empty;
_nasConnection = string.Empty;
_nasSourceRef = string.Empty;
_nasFilter = null;
_nasDescription = null;
_nasIsLocked = false;
}
private void BeginEditNativeSource(TemplateNativeAlarmSource src)
{
_showNativeSourceForm = true;
_editNativeSourceId = src.Id;
_nasFormError = null;
_nasName = src.Name;
_nasConnection = src.ConnectionName;
_nasSourceRef = src.SourceReference;
_nasFilter = src.ConditionFilter;
_nasDescription = src.Description;
_nasIsLocked = src.IsLocked;
}
private void CancelNativeSourceForm()
{
_showNativeSourceForm = false;
_editNativeSourceId = null;
_nasFormError = null;
}
private async Task SaveNativeSource()
{
if (_selectedTemplate == null) return;
_nasFormError = null;
if (string.IsNullOrWhiteSpace(_nasName)) { _nasFormError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_nasConnection)) { _nasFormError = "Connection is required."; return; }
if (string.IsNullOrWhiteSpace(_nasSourceRef)) { _nasFormError = "Source reference is required."; return; }
if (_editNativeSourceId is int id)
{
var existing = await TemplateEngineRepository.GetTemplateNativeAlarmSourceByIdAsync(id);
if (existing == null) { _nasFormError = "Source no longer exists."; return; }
existing.ConnectionName = _nasConnection.Trim();
existing.SourceReference = _nasSourceRef.Trim();
existing.ConditionFilter = string.IsNullOrWhiteSpace(_nasFilter) ? null : _nasFilter.Trim();
existing.Description = string.IsNullOrWhiteSpace(_nasDescription) ? null : _nasDescription.Trim();
existing.IsLocked = _nasIsLocked;
await TemplateEngineRepository.UpdateTemplateNativeAlarmSourceAsync(existing);
await TemplateEngineRepository.SaveChangesAsync();
_showNativeSourceForm = false;
_editNativeSourceId = null;
_toast.ShowSuccess($"Native alarm source '{existing.Name}' updated.");
await LoadAsync();
return;
}
if (_nativeSources.Any(s => string.Equals(s.Name, _nasName.Trim(), StringComparison.OrdinalIgnoreCase)))
{
_nasFormError = $"A source named '{_nasName.Trim()}' already exists on this template.";
return;
}
var source = new TemplateNativeAlarmSource(_nasName.Trim())
{
TemplateId = _selectedTemplate.Id,
ConnectionName = _nasConnection.Trim(),
SourceReference = _nasSourceRef.Trim(),
ConditionFilter = string.IsNullOrWhiteSpace(_nasFilter) ? null : _nasFilter.Trim(),
Description = string.IsNullOrWhiteSpace(_nasDescription) ? null : _nasDescription.Trim(),
IsLocked = _nasIsLocked
};
await TemplateEngineRepository.AddTemplateNativeAlarmSourceAsync(source);
await TemplateEngineRepository.SaveChangesAsync();
_showNativeSourceForm = false;
_toast.ShowSuccess($"Native alarm source '{source.Name}' added.");
await LoadAsync();
}
private async Task DeleteNativeSource(TemplateNativeAlarmSource src)
{
var confirmed = await Dialog.ConfirmAsync("Delete Native Alarm Source", $"Delete native alarm source '{src.Name}'?", danger: true);
if (!confirmed) return;
await TemplateEngineRepository.DeleteTemplateNativeAlarmSourceAsync(src.Id);
await TemplateEngineRepository.SaveChangesAsync();
_toast.ShowSuccess($"Native alarm source '{src.Name}' deleted.");
await LoadAsync();
}
// ---- Scripts Tab ----
private RenderFragment RenderScriptsTab() => __builder =>
{
@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
/// <summary>
/// Task 24: the template editor exposes a Native Alarm Sources subsection (a new
/// tab) for authoring read-only native alarm bindings. <c>TemplateEdit</c> is a
/// heavyweight page (~10 injected services with their own graphs); like the other
/// TemplateEdit coverage in this suite (see <c>TestRunWarningTests</c>), these are
/// structural assertions over the component source that pin the subsection's
/// wiring — the tab, the authoring form (connection dropdown, source reference,
/// filter, lock), and the repository-direct CRUD calls — so it cannot silently
/// regress. The CRUD behaviour itself is covered end-to-end by the ManagementActor
/// native-alarm-source handler tests.
/// </summary>
public class TemplateNativeAlarmSourceEditorTests
{
private static string TemplateEditMarkup
{
get
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 6 && dir is not null; i++)
dir = Directory.GetParent(dir)?.FullName;
return File.ReadAllText(Path.Combine(dir!, "src", "ZB.MOM.WW.ScadaBridge.CentralUI",
"Components", "Pages", "Design", "TemplateEdit.razor"));
}
}
[Fact]
public void TemplateEditor_HasNativeAlarmsTab()
{
var markup = TemplateEditMarkup;
Assert.Contains("_activeTab = \"native-alarms\"", markup);
Assert.Contains("Native Alarms", markup);
Assert.Contains("RenderNativeAlarmsTab", markup);
Assert.Contains("Native Alarm Sources", markup);
}
[Fact]
public void NativeAlarmsForm_HasConnectionSourceFilterAndLockFields()
{
var markup = TemplateEditMarkup;
// Connection dropdown filtered to alarm-capable protocols.
Assert.Contains("_alarmCapableConnections", markup);
Assert.Contains("IsAlarmCapable", markup);
Assert.Contains("OpcUa", markup);
Assert.Contains("MxGateway", markup);
// The authoring form fields.
Assert.Contains("@bind=\"_nasName\"", markup);
Assert.Contains("@bind=\"_nasConnection\"", markup);
Assert.Contains("@bind=\"_nasSourceRef\"", markup);
Assert.Contains("@bind=\"_nasFilter\"", markup);
Assert.Contains("@bind=\"_nasIsLocked\"", markup);
}
[Fact]
public void NativeAlarmsCrud_WiresRepositoryAddUpdateDeleteWithSave()
{
var markup = TemplateEditMarkup;
Assert.Contains("AddTemplateNativeAlarmSourceAsync", markup);
Assert.Contains("UpdateTemplateNativeAlarmSourceAsync", markup);
Assert.Contains("DeleteTemplateNativeAlarmSourceAsync", markup);
Assert.Contains("GetNativeAlarmSourcesByTemplateIdAsync", markup);
Assert.Contains("SaveChangesAsync", markup);
// Add/edit/delete handlers are wired to the UI.
Assert.Contains("BeginAddNativeSource", markup);
Assert.Contains("BeginEditNativeSource", markup);
Assert.Contains("SaveNativeSource", markup);
Assert.Contains("DeleteNativeSource", markup);
}
}