Phases 4-6: Complete Central UI — Admin, Design, Deployment, and Operations pages

Phase 4 — Operator/Admin UI:
- Sites, DataConnections, Areas (hierarchical), API Keys (auto-generated) CRUD
- Health Dashboard (live refresh, per-site metrics from CentralHealthAggregator)
- Instance list with filtering/staleness/lifecycle actions
- Deployment status tracking with auto-refresh

Phase 5 — Authoring UI:
- Template authoring with inheritance tree, tabs (attrs/alarms/scripts/compositions)
- Lock indicators, on-demand validation, collision detection
- Shared scripts with syntax check
- External systems, DB connections, notification lists, Inbound API methods

Phase 6 — Deployment Operations UI:
- Staleness indicators, validation gating
- Debug view (instance selection, attribute/alarm live tables)
- Site event log viewer (filters, keyword search, keyset pagination)
- Parked message management, Audit log viewer with JSON state

Shared components: DataTable, ConfirmDialog, ToastNotification, LoadingSpinner, TimestampDisplay
623 tests pass, zero warnings. All Bootstrap 5, clean corporate design.
This commit is contained in:
Joseph Doherty
2026-03-16 21:47:37 -04:00
parent 6ea38faa6f
commit 3b2320bd35
22 changed files with 4821 additions and 32 deletions

View File

@@ -1,8 +1,437 @@
@page "/design/external-systems"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.ExternalSystems
@using ScadaLink.Commons.Entities.Notifications
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IExternalSystemRepository ExternalSystemRepository
@inject INotificationRepository NotificationRepository
@inject IInboundApiRepository InboundApiRepository
<div class="container mt-4">
<h4>External Systems</h4>
<p class="text-muted">External system management will be available in a future phase.</p>
<div class="container-fluid mt-3">
<h4 class="mb-3">Integration Definitions</h4>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(_tab == "extsys" ? "active" : "")" @onclick='() => _tab = "extsys"'>
External Systems <span class="badge bg-secondary">@_externalSystems.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_tab == "dbconn" ? "active" : "")" @onclick='() => _tab = "dbconn"'>
Database Connections <span class="badge bg-secondary">@_dbConnections.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_tab == "notif" ? "active" : "")" @onclick='() => _tab = "notif"'>
Notification Lists <span class="badge bg-secondary">@_notificationLists.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_tab == "inbound" ? "active" : "")" @onclick='() => _tab = "inbound"'>
Inbound API Methods <span class="badge bg-secondary">@_apiMethods.Count</span>
</button>
</li>
</ul>
@if (_tab == "extsys") { @RenderExternalSystems() }
else if (_tab == "dbconn") { @RenderDbConnections() }
else if (_tab == "notif") { @RenderNotificationLists() }
else if (_tab == "inbound") { @RenderInboundApiMethods() }
}
</div>
@code {
private bool _loading = true;
private string? _errorMessage;
private string _tab = "extsys";
// External Systems
private List<ExternalSystemDefinition> _externalSystems = new();
private bool _showExtSysForm;
private ExternalSystemDefinition? _editingExtSys;
private string _extSysName = "", _extSysUrl = "", _extSysAuth = "ApiKey";
private string? _extSysAuthConfig;
private string? _extSysFormError;
// Database Connections
private List<DatabaseConnectionDefinition> _dbConnections = new();
private bool _showDbConnForm;
private DatabaseConnectionDefinition? _editingDbConn;
private string _dbConnName = "", _dbConnString = "";
private string? _dbConnFormError;
// Notification Lists
private List<NotificationList> _notificationLists = new();
private bool _showNotifForm;
private NotificationList? _editingNotifList;
private string _notifName = "";
private string? _notifFormError;
// Notification Recipients
private Dictionary<int, List<NotificationRecipient>> _recipients = new();
private bool _showRecipientForm;
private int _recipientListId;
private string _recipientName = "", _recipientEmail = "";
private string? _recipientFormError;
// Inbound API Methods
private List<ApiMethod> _apiMethods = new();
private bool _showApiMethodForm;
private ApiMethod? _editingApiMethod;
private string _apiMethodName = "", _apiMethodScript = "";
private int _apiMethodTimeout = 30;
private string? _apiMethodParams, _apiMethodReturn;
private string? _apiMethodFormError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
await LoadAllAsync();
}
private async Task LoadAllAsync()
{
_loading = true;
try
{
_externalSystems = (await ExternalSystemRepository.GetAllExternalSystemsAsync()).ToList();
_dbConnections = (await ExternalSystemRepository.GetAllDatabaseConnectionsAsync()).ToList();
_notificationLists = (await NotificationRepository.GetAllNotificationListsAsync()).ToList();
_recipients.Clear();
foreach (var list in _notificationLists)
{
var recips = await NotificationRepository.GetRecipientsByListIdAsync(list.Id);
if (recips.Count > 0) _recipients[list.Id] = recips.ToList();
}
_apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
}
catch (Exception ex) { _errorMessage = ex.Message; }
_loading = false;
}
// ==== External Systems ====
private RenderFragment RenderExternalSystems() => __builder =>
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">External Systems</h6>
<button class="btn btn-primary btn-sm" @onclick="ShowExtSysAddForm">Add</button>
</div>
@if (_showExtSysForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_extSysName" /></div>
<div class="col-md-3"><label class="form-label small">Endpoint URL</label><input type="text" class="form-control form-control-sm" @bind="_extSysUrl" /></div>
<div class="col-md-2"><label class="form-label small">Auth Type</label>
<select class="form-select form-select-sm" @bind="_extSysAuth"><option>ApiKey</option><option>BasicAuth</option></select></div>
<div class="col-md-3"><label class="form-label small">Auth Config (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_extSysAuthConfig" /></div>
<div class="col-md-2">
<button class="btn btn-success btn-sm me-1" @onclick="SaveExtSys">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showExtSysForm = false">Cancel</button></div>
</div>
@if (_extSysFormError != null) { <div class="text-danger small mt-1">@_extSysFormError</div> }
</div></div>
}
<table class="table table-sm table-striped">
<thead class="table-dark"><tr><th>Name</th><th>URL</th><th>Auth</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var es in _externalSystems)
{
<tr>
<td>@es.Name</td><td class="small">@es.EndpointUrl</td><td><span class="badge bg-secondary">@es.AuthType</span></td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingExtSys = es; _extSysName = es.Name; _extSysUrl = es.EndpointUrl; _extSysAuth = es.AuthType; _extSysAuthConfig = es.AuthConfiguration; _showExtSysForm = true; }">Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteExtSys(es)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
private void ShowExtSysAddForm()
{
_showExtSysForm = true;
_editingExtSys = null;
_extSysName = _extSysUrl = string.Empty;
_extSysAuth = "ApiKey";
_extSysAuthConfig = null;
_extSysFormError = null;
}
private async Task SaveExtSys()
{
_extSysFormError = null;
if (string.IsNullOrWhiteSpace(_extSysName) || string.IsNullOrWhiteSpace(_extSysUrl)) { _extSysFormError = "Name and URL required."; return; }
try
{
if (_editingExtSys != null) { _editingExtSys.Name = _extSysName.Trim(); _editingExtSys.EndpointUrl = _extSysUrl.Trim(); _editingExtSys.AuthType = _extSysAuth; _editingExtSys.AuthConfiguration = _extSysAuthConfig?.Trim(); await ExternalSystemRepository.UpdateExternalSystemAsync(_editingExtSys); }
else { var es = new ExternalSystemDefinition(_extSysName.Trim(), _extSysUrl.Trim(), _extSysAuth) { AuthConfiguration = _extSysAuthConfig?.Trim() }; await ExternalSystemRepository.AddExternalSystemAsync(es); }
await ExternalSystemRepository.SaveChangesAsync(); _showExtSysForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _extSysFormError = ex.Message; }
}
private async Task DeleteExtSys(ExternalSystemDefinition es)
{
if (!await _confirmDialog.ShowAsync($"Delete '{es.Name}'?", "Delete External System")) return;
try { await ExternalSystemRepository.DeleteExternalSystemAsync(es.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
// ==== Database Connections ====
private RenderFragment RenderDbConnections() => __builder =>
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">Database Connections</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showDbConnForm = true; _editingDbConn = null; _dbConnName = _dbConnString = string.Empty; _dbConnFormError = null; }">Add</button>
</div>
@if (_showDbConnForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_dbConnName" /></div>
<div class="col-md-6"><label class="form-label small">Connection String</label><input type="text" class="form-control form-control-sm" @bind="_dbConnString" /></div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveDbConn">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showDbConnForm = false">Cancel</button></div>
</div>
@if (_dbConnFormError != null) { <div class="text-danger small mt-1">@_dbConnFormError</div> }
</div></div>
}
<table class="table table-sm table-striped">
<thead class="table-dark"><tr><th>Name</th><th>Connection String</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var dc in _dbConnections)
{
<tr>
<td>@dc.Name</td><td class="small text-muted text-truncate" style="max-width:400px;">@dc.ConnectionString</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingDbConn = dc; _dbConnName = dc.Name; _dbConnString = dc.ConnectionString; _showDbConnForm = true; }">Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteDbConn(dc)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
private async Task SaveDbConn()
{
_dbConnFormError = null;
if (string.IsNullOrWhiteSpace(_dbConnName) || string.IsNullOrWhiteSpace(_dbConnString)) { _dbConnFormError = "Name and connection string required."; return; }
try
{
if (_editingDbConn != null) { _editingDbConn.Name = _dbConnName.Trim(); _editingDbConn.ConnectionString = _dbConnString.Trim(); await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_editingDbConn); }
else { var dc = new DatabaseConnectionDefinition(_dbConnName.Trim(), _dbConnString.Trim()); await ExternalSystemRepository.AddDatabaseConnectionAsync(dc); }
await ExternalSystemRepository.SaveChangesAsync(); _showDbConnForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _dbConnFormError = ex.Message; }
}
private async Task DeleteDbConn(DatabaseConnectionDefinition dc)
{
if (!await _confirmDialog.ShowAsync($"Delete '{dc.Name}'?", "Delete DB Connection")) return;
try { await ExternalSystemRepository.DeleteDatabaseConnectionAsync(dc.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
// ==== Notification Lists ====
private RenderFragment RenderNotificationLists() => __builder =>
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">Notification Lists</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showNotifForm = true; _editingNotifList = null; _notifName = string.Empty; _notifFormError = null; }">Add List</button>
</div>
@if (_showNotifForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_notifName" /></div>
<div class="col-md-4">
<button class="btn btn-success btn-sm me-1" @onclick="SaveNotifList">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showNotifForm = false">Cancel</button></div>
</div>
@if (_notifFormError != null) { <div class="text-danger small mt-1">@_notifFormError</div> }
</div></div>
}
@if (_showRecipientForm)
{
<div class="card mb-2"><div class="card-body">
<h6 class="card-title small">Add Recipient</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_recipientName" /></div>
<div class="col-md-3"><label class="form-label small">Email</label><input type="email" class="form-control form-control-sm" @bind="_recipientEmail" /></div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveRecipient">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showRecipientForm = false">Cancel</button></div>
</div>
@if (_recipientFormError != null) { <div class="text-danger small mt-1">@_recipientFormError</div> }
</div></div>
}
@foreach (var list in _notificationLists)
{
<div class="card mb-2">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<strong>@list.Name</strong>
<div>
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1" @onclick="() => { _showRecipientForm = true; _recipientListId = list.Id; _recipientName = _recipientEmail = string.Empty; _recipientFormError = null; }">+ Recipient</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteNotifList(list)">Delete</button>
</div>
</div>
<div class="card-body p-2">
@{
var recips = _recipients.GetValueOrDefault(list.Id);
}
@if (recips == null || recips.Count == 0)
{
<span class="text-muted small">No recipients.</span>
}
else
{
@foreach (var r in recips)
{
<span class="badge bg-light text-dark me-1 mb-1">
@r.Name &lt;@r.EmailAddress&gt;
<button type="button" class="btn-close ms-1" style="font-size: 0.5rem;" @onclick="() => DeleteRecipient(r)"></button>
</span>
}
}
</div>
</div>
}
};
private async Task SaveNotifList()
{
_notifFormError = null;
if (string.IsNullOrWhiteSpace(_notifName)) { _notifFormError = "Name required."; return; }
try
{
if (_editingNotifList != null) { _editingNotifList.Name = _notifName.Trim(); await NotificationRepository.UpdateNotificationListAsync(_editingNotifList); }
else { var nl = new NotificationList(_notifName.Trim()); await NotificationRepository.AddNotificationListAsync(nl); }
await NotificationRepository.SaveChangesAsync(); _showNotifForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _notifFormError = ex.Message; }
}
private async Task DeleteNotifList(NotificationList list)
{
if (!await _confirmDialog.ShowAsync($"Delete notification list '{list.Name}'?", "Delete")) return;
try { await NotificationRepository.DeleteNotificationListAsync(list.Id); await NotificationRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
private async Task SaveRecipient()
{
_recipientFormError = null;
if (string.IsNullOrWhiteSpace(_recipientName) || string.IsNullOrWhiteSpace(_recipientEmail)) { _recipientFormError = "Name and email required."; return; }
try
{
var r = new NotificationRecipient(_recipientName.Trim(), _recipientEmail.Trim()) { NotificationListId = _recipientListId };
await NotificationRepository.AddRecipientAsync(r); await NotificationRepository.SaveChangesAsync();
_showRecipientForm = false; _toast.ShowSuccess("Recipient added."); await LoadAllAsync();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
private async Task DeleteRecipient(NotificationRecipient r)
{
try { await NotificationRepository.DeleteRecipientAsync(r.Id); await NotificationRepository.SaveChangesAsync(); _toast.ShowSuccess("Removed."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
// ==== Inbound API Methods ====
private RenderFragment RenderInboundApiMethods() => __builder =>
{
<div class="d-flex justify-content-between mb-2">
<h6 class="mb-0">Inbound API Methods</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showApiMethodForm = true; _editingApiMethod = null; _apiMethodName = _apiMethodScript = string.Empty; _apiMethodTimeout = 30; _apiMethodParams = _apiMethodReturn = null; _apiMethodFormError = null; }">Add Method</button>
</div>
@if (_showApiMethodForm)
{
<div class="card mb-2"><div class="card-body">
<div class="row g-2">
<div class="col-md-3"><label class="form-label small">Name</label><input type="text" class="form-control form-control-sm" @bind="_apiMethodName" disabled="@(_editingApiMethod != null)" /></div>
<div class="col-md-2"><label class="form-label small">Timeout (s)</label><input type="number" class="form-control form-control-sm" @bind="_apiMethodTimeout" /></div>
<div class="col-md-3"><label class="form-label small">Params (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_apiMethodParams" /></div>
<div class="col-md-3"><label class="form-label small">Returns (JSON)</label><input type="text" class="form-control form-control-sm" @bind="_apiMethodReturn" /></div>
</div>
<div class="mt-2">
<label class="form-label small">Script</label>
<textarea class="form-control form-control-sm font-monospace" rows="5" @bind="_apiMethodScript" style="font-size: 0.8rem;"></textarea>
</div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="SaveApiMethod">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showApiMethodForm = false">Cancel</button>
</div>
@if (_apiMethodFormError != null) { <div class="text-danger small mt-1">@_apiMethodFormError</div> }
</div></div>
}
<table class="table table-sm table-striped">
<thead class="table-dark"><tr><th>Name</th><th>Timeout</th><th>Script (preview)</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@foreach (var m in _apiMethods)
{
<tr>
<td><code>POST /api/@m.Name</code></td>
<td>@m.TimeoutSeconds s</td>
<td class="small font-monospace text-truncate" style="max-width:300px;">@m.Script[..Math.Min(60, m.Script.Length)]</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick="() => { _editingApiMethod = m; _apiMethodName = m.Name; _apiMethodScript = m.Script; _apiMethodTimeout = m.TimeoutSeconds; _apiMethodParams = m.ParameterDefinitions; _apiMethodReturn = m.ReturnDefinition; _showApiMethodForm = true; }">Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteApiMethod(m)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
private async Task SaveApiMethod()
{
_apiMethodFormError = null;
if (string.IsNullOrWhiteSpace(_apiMethodName) || string.IsNullOrWhiteSpace(_apiMethodScript)) { _apiMethodFormError = "Name and script required."; return; }
try
{
if (_editingApiMethod != null) { _editingApiMethod.Script = _apiMethodScript; _editingApiMethod.TimeoutSeconds = _apiMethodTimeout; _editingApiMethod.ParameterDefinitions = _apiMethodParams?.Trim(); _editingApiMethod.ReturnDefinition = _apiMethodReturn?.Trim(); await InboundApiRepository.UpdateApiMethodAsync(_editingApiMethod); }
else { var m = new ApiMethod(_apiMethodName.Trim(), _apiMethodScript) { TimeoutSeconds = _apiMethodTimeout, ParameterDefinitions = _apiMethodParams?.Trim(), ReturnDefinition = _apiMethodReturn?.Trim() }; await InboundApiRepository.AddApiMethodAsync(m); }
await InboundApiRepository.SaveChangesAsync(); _showApiMethodForm = false; _toast.ShowSuccess("Saved."); await LoadAllAsync();
}
catch (Exception ex) { _apiMethodFormError = ex.Message; }
}
private async Task DeleteApiMethod(ApiMethod m)
{
if (!await _confirmDialog.ShowAsync($"Delete API method '{m.Name}'?", "Delete")) return;
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
}

View File

@@ -1,8 +1,286 @@
@page "/design/shared-scripts"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Scripts
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.TemplateEngine
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject SharedScriptService SharedScriptService
<div class="container mt-4">
<h4>Shared Scripts</h4>
<p class="text-muted">Shared script management will be available in a future phase.</p>
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Shared Scripts</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">New Script</button>
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingScript == null ? "New Shared Script" : $"Edit: {_editingScript.Name}")</h6>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName"
disabled="@(_editingScript != null)" />
</div>
<div class="col-md-4">
<label class="form-label small">Parameters (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formParameters"
placeholder='e.g. [{"name":"x","type":"Int32"}]' />
</div>
<div class="col-md-4">
<label class="form-label small">Return Definition (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formReturn"
placeholder='e.g. {"type":"Boolean"}' />
</div>
</div>
<div class="mt-2">
<label class="form-label small">Code</label>
<textarea class="form-control form-control-sm font-monospace" rows="10" @bind="_formCode"
style="font-size: 0.8rem;"></textarea>
</div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
@if (_syntaxCheckResult != null)
{
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
}
</div>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Code (preview)</th>
<th>Parameters</th>
<th>Returns</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_scripts.Count == 0)
{
<tr>
<td colspan="6" class="text-muted text-center">No shared scripts configured.</td>
</tr>
}
@foreach (var script in _scripts)
{
<tr>
<td>@script.Id</td>
<td><strong>@script.Name</strong></td>
<td class="small text-muted font-monospace text-truncate" style="max-width: 300px;">
@script.Code[..Math.Min(60, script.Code.Length)]@(script.Code.Length > 60 ? "..." : "")
</td>
<td class="small text-muted">@(script.ParameterDefinitions ?? "—")</td>
<td class="small text-muted">@(script.ReturnDefinition ?? "—")</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditScript(script)">Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScript(script)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
@code {
private List<SharedScript> _scripts = new();
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private SharedScript? _editingScript;
private string _formName = string.Empty;
private string _formCode = string.Empty;
private string? _formParameters;
private string? _formReturn;
private string? _formError;
private string? _syntaxCheckResult;
private bool _syntaxCheckPassed;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_loading = true;
_errorMessage = null;
try
{
_scripts = (await SharedScriptService.GetAllSharedScriptsAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load shared scripts: {ex.Message}";
}
_loading = false;
}
private void ShowAddForm()
{
_editingScript = null;
_formName = string.Empty;
_formCode = string.Empty;
_formParameters = null;
_formReturn = null;
_formError = null;
_syntaxCheckResult = null;
_showForm = true;
}
private void EditScript(SharedScript script)
{
_editingScript = script;
_formName = script.Name;
_formCode = script.Code;
_formParameters = script.ParameterDefinitions;
_formReturn = script.ReturnDefinition;
_formError = null;
_syntaxCheckResult = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingScript = null;
}
private void CheckCompilation()
{
var syntaxError = ValidateSyntaxLocally(_formCode);
if (syntaxError == null)
{
_syntaxCheckResult = "Syntax check passed.";
_syntaxCheckPassed = true;
}
else
{
_syntaxCheckResult = syntaxError;
_syntaxCheckPassed = false;
}
}
private async Task SaveScript()
{
_formError = null;
_syntaxCheckResult = null;
try
{
if (_editingScript != null)
{
var result = await SharedScriptService.UpdateSharedScriptAsync(
_editingScript.Id, _formCode, _formParameters?.Trim(), _formReturn?.Trim(), "system");
if (result.IsSuccess)
{
_showForm = false;
_toast.ShowSuccess($"Script '{_editingScript.Name}' updated.");
await LoadDataAsync();
}
else
{
_formError = result.Error;
}
}
else
{
var result = await SharedScriptService.CreateSharedScriptAsync(
_formName.Trim(), _formCode, _formParameters?.Trim(), _formReturn?.Trim(), "system");
if (result.IsSuccess)
{
_showForm = false;
_toast.ShowSuccess($"Script '{_formName}' created.");
await LoadDataAsync();
}
else
{
_formError = result.Error;
}
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
/// <summary>
/// Basic syntax check: balanced braces/brackets/parens.
/// Mirrors the internal SharedScriptService.ValidateSyntax logic.
/// </summary>
private static string? ValidateSyntaxLocally(string code)
{
if (string.IsNullOrWhiteSpace(code)) return "Script code cannot be empty.";
int brace = 0, bracket = 0, paren = 0;
foreach (var ch in code)
{
switch (ch) { case '{': brace++; break; case '}': brace--; break; case '[': bracket++; break; case ']': bracket--; break; case '(': paren++; break; case ')': paren--; break; }
if (brace < 0) return "Syntax error: unmatched closing brace '}'.";
if (bracket < 0) return "Syntax error: unmatched closing bracket ']'.";
if (paren < 0) return "Syntax error: unmatched closing parenthesis ')'.";
}
if (brace != 0) return "Syntax error: unmatched opening brace '{'.";
if (bracket != 0) return "Syntax error: unmatched opening bracket '['.";
if (paren != 0) return "Syntax error: unmatched opening parenthesis '('.";
return null;
}
private async Task DeleteScript(SharedScript script)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete shared script '{script.Name}'?", "Delete Shared Script");
if (!confirmed) return;
try
{
var result = await SharedScriptService.DeleteSharedScriptAsync(script.Id, "system");
if (result.IsSuccess)
{
_toast.ShowSuccess($"Script '{script.Name}' deleted.");
await LoadDataAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
}

View File

@@ -1,8 +1,980 @@
@page "/design/templates"
@page "/design/templates/{TemplateIdParam:int}"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.TemplateEngine
@using ScadaLink.TemplateEngine.Validation
@using ScadaLink.TemplateEngine.Flattening
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
<div class="container mt-4">
<h4>Templates</h4>
<p class="text-muted">Template management will be available in a future phase.</p>
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else if (_selectedTemplate == null)
{
@* Template list view *@
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Templates</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowCreateForm">New Template</button>
</div>
@if (_showCreateForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Create Template</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" />
</div>
<div class="col-md-3">
<label class="form-label small">Parent Template</label>
<select class="form-select form-select-sm" @bind="_createParentId">
<option value="0">(None - root template)</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_createDescription" />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateTemplate">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCreateForm = false">Cancel</button>
</div>
</div>
@if (_createError != null)
{
<div class="text-danger small mt-1">@_createError</div>
}
</div>
</div>
}
@* Inheritance tree visualization *@
<div class="card">
<div class="card-body p-2">
@foreach (var node in BuildTemplateTree())
{
<div class="d-flex align-items-center py-1 border-bottom"
style="padding-left: @(node.Depth * 24 + 8)px; cursor: pointer;"
@onclick="() => SelectTemplate(node.Template.Id)">
<span class="me-2 text-muted small">@(node.HasChildren ? "[+]" : " -")</span>
<span class="flex-grow-1">
<strong>@node.Template.Name</strong>
@if (node.Template.ParentTemplateId.HasValue)
{
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == node.Template.ParentTemplateId)?.Name)</span>
}
@if (!string.IsNullOrEmpty(node.Template.Description))
{
<span class="text-muted small ms-2">@node.Template.Description</span>
}
</span>
<span class="badge bg-light text-dark me-2">
@node.Template.Attributes.Count attr, @node.Template.Alarms.Count alm, @node.Template.Scripts.Count scr
</span>
@if (node.Template.Compositions.Count > 0)
{
<span class="badge bg-info text-dark me-2">@node.Template.Compositions.Count comp</span>
}
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteTemplate(node.Template)" @onclick:stopPropagation="true">Delete</button>
</div>
}
</div>
</div>
}
else
{
@* Template detail/edit view *@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<button class="btn btn-outline-secondary btn-sm me-2" @onclick="BackToList">Back to List</button>
<h4 class="d-inline mb-0">@_selectedTemplate.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@if (_validating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Validate
</button>
</div>
</div>
@* Validation results *@
@if (_validationResult != null)
{
<div class="mb-3">
@if (_validationResult.Errors.Count > 0)
{
<div class="alert alert-danger py-2">
<strong>Validation Errors (@_validationResult.Errors.Count)</strong>
<ul class="mb-0 small">
@foreach (var err in _validationResult.Errors)
{
<li>[@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")</li>
}
</ul>
</div>
}
@if (_validationResult.Warnings.Count > 0)
{
<div class="alert alert-warning py-2">
<strong>Warnings (@_validationResult.Warnings.Count)</strong>
<ul class="mb-0 small">
@foreach (var warn in _validationResult.Warnings)
{
<li>[@warn.Category] @warn.Message</li>
}
</ul>
</div>
}
@if (_validationResult.Errors.Count == 0 && _validationResult.Warnings.Count == 0)
{
<div class="alert alert-success py-2">Validation passed with no errors or warnings.</div>
}
</div>
}
@* Template info edit *@
<div class="card mb-3">
<div class="card-header">Template Properties</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_editName" />
</div>
<div class="col-md-4">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_editDescription" />
</div>
<div class="col-md-3">
<label class="form-label small">Parent Template</label>
<select class="form-select form-select-sm" @bind="_editParentId">
<option value="0">(None)</option>
@foreach (var t in _templates.Where(t => t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-primary btn-sm" @onclick="UpdateTemplateProperties">Save Properties</button>
</div>
</div>
</div>
</div>
@* Tabs: Attributes, Alarms, Scripts, Compositions *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(_activeTab == "attributes" ? "active" : "")" @onclick='() => _activeTab = "attributes"'>
Attributes <span class="badge bg-secondary">@_attributes.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "alarms" ? "active" : "")" @onclick='() => _activeTab = "alarms"'>
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")" @onclick='() => _activeTab = "scripts"'>
Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "compositions" ? "active" : "")" @onclick='() => _activeTab = "compositions"'>
Compositions <span class="badge bg-secondary">@_compositions.Count</span>
</button>
</li>
</ul>
@if (_activeTab == "attributes")
{
@RenderAttributesTab()
}
else if (_activeTab == "alarms")
{
@RenderAlarmsTab()
}
else if (_activeTab == "scripts")
{
@RenderScriptsTab()
}
else if (_activeTab == "compositions")
{
@RenderCompositionsTab()
}
}
</div>
@code {
[Parameter] public int TemplateIdParam { get; set; }
private List<Template> _templates = new();
private Template? _selectedTemplate;
private List<TemplateAttribute> _attributes = new();
private List<TemplateAlarm> _alarms = new();
private List<TemplateScript> _scripts = new();
private List<TemplateComposition> _compositions = new();
private bool _loading = true;
private string? _errorMessage;
private string _activeTab = "attributes";
// Create form
private bool _showCreateForm;
private string _createName = string.Empty;
private int _createParentId;
private string? _createDescription;
private string? _createError;
// Edit properties
private string _editName = string.Empty;
private string? _editDescription;
private int _editParentId;
// Validation
private bool _validating;
private Commons.Types.Flattening.ValidationResult? _validationResult;
// Member add forms
private bool _showAttrForm;
private string _attrName = string.Empty;
private string? _attrValue;
private DataType _attrDataType;
private bool _attrIsLocked;
private string? _attrDataSourceRef;
private string? _attrFormError;
private bool _showAlarmForm;
private string _alarmName = string.Empty;
private int _alarmPriority;
private AlarmTriggerType _alarmTriggerType;
private string? _alarmTriggerConfig;
private bool _alarmIsLocked;
private string? _alarmFormError;
private bool _showScriptForm;
private string _scriptName = string.Empty;
private string _scriptCode = string.Empty;
private string? _scriptTriggerType;
private string? _scriptTriggerConfig;
private bool _scriptIsLocked;
private string? _scriptFormError;
private bool _showCompForm;
private int _compComposedTemplateId;
private string _compInstanceName = string.Empty;
private string? _compFormError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
await LoadTemplatesAsync();
if (TemplateIdParam > 0)
{
await SelectTemplate(TemplateIdParam);
}
}
private async Task LoadTemplatesAsync()
{
_loading = true;
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load templates: {ex.Message}";
}
_loading = false;
}
private record TemplateTreeNode(Template Template, int Depth, bool HasChildren);
private List<TemplateTreeNode> BuildTemplateTree()
{
var result = new List<TemplateTreeNode>();
AddTemplateChildren(null, 0, result);
return result;
}
private void AddTemplateChildren(int? parentId, int depth, List<TemplateTreeNode> result)
{
var children = _templates.Where(t => t.ParentTemplateId == parentId).OrderBy(t => t.Name);
foreach (var child in children)
{
var hasChildren = _templates.Any(t => t.ParentTemplateId == child.Id);
result.Add(new TemplateTreeNode(child, depth, hasChildren));
AddTemplateChildren(child.Id, depth + 1, result);
}
}
private async Task SelectTemplate(int templateId)
{
try
{
_selectedTemplate = await TemplateEngineRepository.GetTemplateWithChildrenAsync(templateId)
?? _templates.FirstOrDefault(t => t.Id == templateId);
if (_selectedTemplate == null) return;
_editName = _selectedTemplate.Name;
_editDescription = _selectedTemplate.Description;
_editParentId = _selectedTemplate.ParentTemplateId ?? 0;
_attributes = (await TemplateEngineRepository.GetAttributesByTemplateIdAsync(templateId)).ToList();
_alarms = (await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(templateId)).ToList();
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(templateId)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(templateId)).ToList();
_validationResult = null;
}
catch (Exception ex)
{
_toast.ShowError($"Failed to load template: {ex.Message}");
}
}
private void BackToList()
{
_selectedTemplate = null;
_validationResult = null;
}
private void ShowCreateForm()
{
_createName = string.Empty;
_createParentId = 0;
_createDescription = null;
_createError = null;
_showCreateForm = true;
}
private async Task CreateTemplate()
{
_createError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _createError = "Name is required."; return; }
try
{
var result = await TemplateService.CreateTemplateAsync(
_createName.Trim(), _createDescription?.Trim(),
_createParentId == 0 ? null : _createParentId, "system");
if (result.IsSuccess)
{
_showCreateForm = false;
_toast.ShowSuccess($"Template '{_createName}' created.");
await LoadTemplatesAsync();
}
else
{
_createError = result.Error;
}
}
catch (Exception ex)
{
_createError = $"Create failed: {ex.Message}";
}
}
private async Task DeleteTemplate(Template template)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete template '{template.Name}'? This will fail if instances or child templates reference it.",
"Delete Template");
if (!confirmed) return;
try
{
var result = await TemplateService.DeleteTemplateAsync(template.Id, "system");
if (result.IsSuccess)
{
_toast.ShowSuccess($"Template '{template.Name}' deleted.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
private async Task UpdateTemplateProperties()
{
if (_selectedTemplate == null) return;
try
{
var result = await TemplateService.UpdateTemplateAsync(
_selectedTemplate.Id, _editName.Trim(), _editDescription?.Trim(),
_editParentId == 0 ? null : _editParentId, "system");
if (result.IsSuccess)
{
_toast.ShowSuccess("Template properties updated.");
await LoadTemplatesAsync();
_selectedTemplate = result.Value;
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Update failed: {ex.Message}");
}
}
private async Task RunValidation()
{
if (_selectedTemplate == null) return;
_validating = true;
_validationResult = null;
try
{
// Use the ValidationService for on-demand validation
var validationService = new ValidationService();
// Build a minimal flattened config from the template's direct members for validation
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
{
InstanceUniqueName = $"validation-{_selectedTemplate.Name}",
TemplateId = _selectedTemplate.Id,
Attributes = _attributes.Select(a => new Commons.Types.Flattening.ResolvedAttribute
{
CanonicalName = a.Name,
Value = a.Value,
DataType = a.DataType.ToString(),
IsLocked = a.IsLocked,
DataSourceReference = a.DataSourceReference
}).ToList(),
Alarms = _alarms.Select(a => new Commons.Types.Flattening.ResolvedAlarm
{
CanonicalName = a.Name,
PriorityLevel = a.PriorityLevel,
IsLocked = a.IsLocked,
TriggerType = a.TriggerType.ToString(),
TriggerConfiguration = a.TriggerConfiguration
}).ToList(),
Scripts = _scripts.Select(s => new Commons.Types.Flattening.ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList()
};
_validationResult = validationService.Validate(flatConfig);
}
catch (Exception ex)
{
_toast.ShowError($"Validation error: {ex.Message}");
}
_validating = false;
}
// ---- Attributes Tab ----
private RenderFragment RenderAttributesTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Attributes</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAttrForm = true; _attrFormError = null; _attrName = string.Empty; _attrValue = null; _attrIsLocked = false; _attrDataSourceRef = null; }">Add Attribute</button>
</div>
@if (_showAttrForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_attrName" />
</div>
<div class="col-md-2">
<label class="form-label small">Data Type</label>
<select class="form-select form-select-sm" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>())
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Value</label>
<input type="text" class="form-control form-control-sm" @bind="_attrValue" />
</div>
<div class="col-md-2">
<label class="form-label small">Data Source Ref</label>
<input type="text" class="form-control form-control-sm" @bind="_attrDataSourceRef" placeholder="Tag path" />
</div>
<div class="col-md-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" />
<label class="form-check-label small" for="attrLocked">Locked</label>
</div>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddAttribute">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAttrForm = false">Cancel</button>
</div>
</div>
@if (_attrFormError != null) { <div class="text-danger small mt-1">@_attrFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Type</th>
<th>Value</th>
<th>Data Source</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _attributes)
{
<tr>
<td>@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td class="small">@(attr.Value ?? "—")</td>
<td class="small text-muted">@(attr.DataSourceReference ?? "—")</td>
<td>
@if (attr.IsLocked)
{
<span class="badge bg-danger" title="Locked">L</span>
}
else
{
<span class="badge bg-light text-dark" title="Unlocked">U</span>
}
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteAttribute(attr)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Alarms</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAlarmForm = true; _alarmFormError = null; _alarmName = string.Empty; _alarmPriority = 500; _alarmTriggerConfig = null; _alarmIsLocked = false; }">Add Alarm</button>
</div>
@if (_showAlarmForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmName" />
</div>
<div class="col-md-2">
<label class="form-label small">Trigger Type</label>
<select class="form-select form-select-sm" @bind="_alarmTriggerType">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
{
<option value="@tt">@tt</option>
}
</select>
</div>
<div class="col-md-1">
<label class="form-label small">Priority</label>
<input type="number" class="form-control form-control-sm" @bind="_alarmPriority" min="0" max="1000" />
</div>
<div class="col-md-3">
<label class="form-label small">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmTriggerConfig" />
</div>
<div class="col-md-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" />
<label class="form-check-label small" for="alarmLocked">Locked</label>
</div>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddAlarm">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAlarmForm = false">Cancel</button>
</div>
</div>
@if (_alarmFormError != null) { <div class="text-danger small mt-1">@_alarmFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Priority</th>
<th>Config</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var alarm in _alarms)
{
<tr>
<td>@alarm.Name</td>
<td><span class="badge bg-light text-dark">@alarm.TriggerType</span></td>
<td>@alarm.PriorityLevel</td>
<td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td>
<td>
@if (alarm.IsLocked) { <span class="badge bg-danger">L</span> }
else { <span class="badge bg-light text-dark">U</span> }
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteAlarm(alarm)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Scripts Tab ----
private RenderFragment RenderScriptsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Scripts</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptIsLocked = false; }">Add Script</button>
</div>
@if (_showScriptForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptName" />
</div>
<div class="col-md-2">
<label class="form-label small">Trigger Type</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-md-3">
<label class="form-label small">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerConfig" />
</div>
<div class="col-md-1">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
<label class="form-check-label small" for="scriptLocked">Locked</label>
</div>
</div>
</div>
<div class="mt-2">
<label class="form-label small">Code</label>
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_scriptCode"
style="font-size: 0.8rem;"></textarea>
</div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="AddScript">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showScriptForm = false">Cancel</button>
</div>
@if (_scriptFormError != null) { <div class="text-danger small mt-1">@_scriptFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Code (preview)</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var script in _scripts)
{
<tr>
<td>@script.Name</td>
<td class="small">@(script.TriggerType ?? "—")</td>
<td class="small text-muted text-truncate font-monospace" style="max-width: 300px;">@script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "")</td>
<td>
@if (script.IsLocked) { <span class="badge bg-danger">L</span> }
else { <span class="badge bg-light text-dark">U</span> }
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScript(script)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Compositions Tab ----
private RenderFragment RenderCompositionsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Compositions</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showCompForm = true; _compFormError = null; _compInstanceName = string.Empty; _compComposedTemplateId = 0; }">Add Composition</button>
</div>
@if (_showCompForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_compInstanceName" />
</div>
<div class="col-md-4">
<label class="form-label small">Composed Template</label>
<select class="form-select form-select-sm" @bind="_compComposedTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates.Where(t => _selectedTemplate == null || t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddComposition">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCompForm = false">Cancel</button>
</div>
</div>
@if (_compFormError != null) { <div class="text-danger small mt-1">@_compFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Instance Name</th>
<th>Composed Template</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var comp in _compositions)
{
<tr>
<td><code>@comp.InstanceName</code></td>
<td>@(_templates.FirstOrDefault(t => t.Id == comp.ComposedTemplateId)?.Name ?? $"#{comp.ComposedTemplateId}")</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteComposition(comp)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- CRUD handlers ----
private async Task AddAttribute()
{
if (_selectedTemplate == null) return;
_attrFormError = null;
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
var attr = new TemplateAttribute(_attrName.Trim())
{
DataType = _attrDataType,
Value = _attrValue?.Trim(),
IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim()
};
var result = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, "system");
if (result.IsSuccess)
{
_showAttrForm = false;
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_attrFormError = result.Error;
}
}
private async Task DeleteAttribute(TemplateAttribute attr)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete attribute '{attr.Name}'?", "Delete Attribute");
if (!confirmed) return;
var result = await TemplateService.DeleteAttributeAsync(attr.Id, "system");
if (result.IsSuccess)
{
_toast.ShowSuccess($"Attribute '{attr.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else
{
_toast.ShowError(result.Error);
}
}
private async Task AddAlarm()
{
if (_selectedTemplate == null) return;
_alarmFormError = null;
if (string.IsNullOrWhiteSpace(_alarmName)) { _alarmFormError = "Name is required."; return; }
var alarm = new TemplateAlarm(_alarmName.Trim())
{
TriggerType = _alarmTriggerType,
PriorityLevel = _alarmPriority,
TriggerConfiguration = _alarmTriggerConfig?.Trim(),
IsLocked = _alarmIsLocked
};
var result = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, "system");
if (result.IsSuccess)
{
_showAlarmForm = false;
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_alarmFormError = result.Error;
}
}
private async Task DeleteAlarm(TemplateAlarm alarm)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete alarm '{alarm.Name}'?", "Delete Alarm");
if (!confirmed) return;
var result = await TemplateService.DeleteAlarmAsync(alarm.Id, "system");
if (result.IsSuccess)
{
_toast.ShowSuccess($"Alarm '{alarm.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else { _toast.ShowError(result.Error); }
}
private async Task AddScript()
{
if (_selectedTemplate == null) return;
_scriptFormError = null;
if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_scriptCode)) { _scriptFormError = "Code is required."; return; }
var script = new TemplateScript(_scriptName.Trim(), _scriptCode)
{
TriggerType = _scriptTriggerType?.Trim(),
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
IsLocked = _scriptIsLocked
};
var result = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, "system");
if (result.IsSuccess)
{
_showScriptForm = false;
_toast.ShowSuccess($"Script '{_scriptName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_scriptFormError = result.Error;
}
}
private async Task DeleteScript(TemplateScript script)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete script '{script.Name}'?", "Delete Script");
if (!confirmed) return;
var result = await TemplateService.DeleteScriptAsync(script.Id, "system");
if (result.IsSuccess)
{
_toast.ShowSuccess($"Script '{script.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else { _toast.ShowError(result.Error); }
}
private async Task AddComposition()
{
if (_selectedTemplate == null) return;
_compFormError = null;
if (string.IsNullOrWhiteSpace(_compInstanceName)) { _compFormError = "Instance name is required."; return; }
if (_compComposedTemplateId == 0) { _compFormError = "Select a template."; return; }
var result = await TemplateService.AddCompositionAsync(
_selectedTemplate.Id, _compComposedTemplateId, _compInstanceName.Trim(), "system");
if (result.IsSuccess)
{
_showCompForm = false;
_toast.ShowSuccess($"Composition '{_compInstanceName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_compFormError = result.Error;
}
}
private async Task DeleteComposition(TemplateComposition comp)
{
var confirmed = await _confirmDialog.ShowAsync($"Remove composition '{comp.InstanceName}'?", "Delete Composition");
if (!confirmed) return;
var result = await TemplateService.DeleteCompositionAsync(comp.Id, "system");
if (result.IsSuccess)
{
_toast.ShowSuccess($"Composition '{comp.InstanceName}' removed.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else { _toast.ShowError(result.Error); }
}
}