feat(ui): template editor Native Alarm Sources subsection
This commit is contained in:
@@ -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 & 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 =>
|
||||
{
|
||||
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user