feat: separate create/edit form pages, Playwright test infrastructure, /auth/token endpoint

Move all CRUD create/edit forms from inline on list pages to dedicated form pages
with back-button navigation and post-save redirect. Add Playwright Docker container
(browser server on port 3000) with 25 passing E2E tests covering login, navigation,
and site CRUD workflows. Add POST /auth/token endpoint for clean JWT retrieval.
This commit is contained in:
Joseph Doherty
2026-03-21 15:17:24 -04:00
parent b3f8850711
commit d3194e3634
31 changed files with 2333 additions and 1117 deletions

View File

@@ -0,0 +1,144 @@
@page "/admin/api-keys/create"
@page "/admin/api-keys/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
@if (_saved)
{
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">&larr; Back to API Keys</a>
}
else
{
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">&larr; Back</a>
}
<h4 class="mb-0">@(_saved ? "API Key Created" : (_editingKey != null ? "Edit API Key" : "Add API Key"))</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_saved && _newlyCreatedKeyValue != null)
{
<div class="alert alert-success">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
</div>
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _errorMessage;
private string? _newlyCreatedKeyValue;
private bool _loading = true;
private bool _saved;
protected override async Task OnInitializedAsync()
{
try
{
if (Id.HasValue)
{
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
if (_editingKey == null)
{
_errorMessage = $"API key with ID {Id.Value} not found.";
}
else
{
_formName = _editingKey.Name;
}
}
}
catch (Exception ex)
{
_errorMessage = $"Failed to load API key: {ex.Message}";
}
_loading = false;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/api-keys");
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
await InboundApiRepository.AddApiKeyAsync(key);
await InboundApiRepository.SaveChangesAsync();
_newlyCreatedKeyValue = keyValue;
_saved = true;
}
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
private void CopyKeyToClipboard()
{
// Note: JS interop for clipboard would be needed for actual copy.
// For now the key is displayed for manual copy.
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}

View File

@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">API Key Management</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add API Key</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/api-keys/create")'>Add API Key</button>
</div>
<ToastNotification @ref="_toast" />
@@ -24,42 +25,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingKey == null ? "Add New API Key" : "Edit API Key")</h6>
<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="_formName" />
</div>
<div class="col-md-4">
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@if (_newlyCreatedKeyValue != null)
{
<div class="alert alert-success alert-dismissible fade show">
<strong>New API Key Created</strong>
<div class="d-flex align-items-center mt-1">
<code class="me-2">@_newlyCreatedKeyValue</code>
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
</div>
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
<button type="button" class="btn-close" @onclick="() => _newlyCreatedKeyValue = null"></button>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@@ -95,7 +60,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditKey(key)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
@if (key.IsEnabled)
{
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@@ -121,12 +86,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private ApiKey? _editingKey;
private string _formName = string.Empty;
private string? _formError;
private string? _newlyCreatedKeyValue;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
@@ -156,63 +115,6 @@
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
}
private void ShowAddForm()
{
_editingKey = null;
_formName = string.Empty;
_formError = null;
_showForm = true;
}
private void EditKey(ApiKey key)
{
_editingKey = key;
_formName = key.Name;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingKey = null;
_formError = null;
}
private async Task SaveKey()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
try
{
if (_editingKey != null)
{
_editingKey.Name = _formName.Trim();
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
}
else
{
var keyValue = GenerateApiKey();
var key = new ApiKey(_formName.Trim(), keyValue)
{
IsEnabled = true
};
await InboundApiRepository.AddApiKeyAsync(key);
_newlyCreatedKeyValue = keyValue;
}
await InboundApiRepository.SaveChangesAsync();
_showForm = false;
_editingKey = null;
_toast.ShowSuccess("API key saved.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task ToggleKey(ApiKey key)
{
try
@@ -247,18 +149,4 @@
}
}
private void CopyKeyToClipboard()
{
// Note: JS interop for clipboard would be needed for actual copy.
// For now the key is displayed for manual copy.
_toast.ShowInfo("Key displayed above. Select and copy manually.");
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
}
}

View File

@@ -0,0 +1,123 @@
@page "/admin/data-connections/create"
@page "/admin/data-connections/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-2">
<label class="form-label small">Protocol</label>
<select class="form-select form-select-sm" @bind="_formProtocol">
<option value="">Select...</option>
<option value="OpcUa">OPC UA</option>
<option value="LmxProxy">LMX Proxy</option>
<option value="Custom">Custom</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small">Configuration (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private DataConnection? _editingConnection;
private string _formName = string.Empty;
private string _formProtocol = string.Empty;
private string? _formConfiguration;
private string? _formError;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
if (_editingConnection != null)
{
_formName = _editingConnection.Name;
_formProtocol = _editingConnection.Protocol;
_formConfiguration = _editingConnection.Configuration;
}
}
catch (Exception ex)
{
_formError = $"Failed to load connection: {ex.Message}";
}
}
_loading = false;
}
private async Task SaveConnection()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _formProtocol;
_editingConnection.Configuration = _formConfiguration?.Trim();
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _formProtocol)
{
Configuration = _formConfiguration?.Trim()
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/data-connections");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/data-connections");
}
}

View File

@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Data Connections</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Connection</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/data-connections/create")'>Add Connection</button>
</div>
<ToastNotification @ref="_toast" />
@@ -24,43 +25,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingConnection == null ? "Add New Connection" : "Edit Connection")</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="_formName" />
</div>
<div class="col-md-2">
<label class="form-label small">Protocol</label>
<select class="form-select form-select-sm" @bind="_formProtocol">
<option value="">Select...</option>
<option value="OpcUa">OPC UA</option>
<option value="LmxProxy">LMX Proxy</option>
<option value="Custom">Custom</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Configuration (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@* Assignment form *@
@if (_showAssignForm)
{
@@ -154,7 +118,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditConnection(conn)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteConnection(conn)">Delete</button>
</td>
@@ -172,13 +136,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private DataConnection? _editingConnection;
private string _formName = string.Empty;
private string _formProtocol = string.Empty;
private string? _formConfiguration;
private string? _formError;
private bool _showAssignForm;
private int _assignConnectionId;
private int _assignSiteId;
@@ -224,67 +181,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingConnection = null;
_formName = string.Empty;
_formProtocol = string.Empty;
_formConfiguration = null;
_formError = null;
_showForm = true;
}
private void EditConnection(DataConnection conn)
{
_editingConnection = conn;
_formName = conn.Name;
_formProtocol = conn.Protocol;
_formConfiguration = conn.Configuration;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingConnection = null;
_formError = null;
}
private async Task SaveConnection()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _formProtocol;
_editingConnection.Configuration = _formConfiguration?.Trim();
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _formProtocol)
{
Configuration = _formConfiguration?.Trim()
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
_showForm = false;
_toast.ShowSuccess("Connection saved.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteConnection(DataConnection conn)
{
var confirmed = await _confirmDialog.ShowAsync(

View File

@@ -0,0 +1,213 @@
@page "/admin/ldap-mappings/create"
@page "/admin/ldap-mappings/{Id:int}/edit"
@using ScadaLink.Commons.Entities.Security
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Security
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISecurityRepository SecurityRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">
&larr; Back
</button>
</div>
<ConfirmDialog @ref="_confirmDialog" />
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(IsEditMode ? "Edit LDAP Mapping" : "Add LDAP Mapping")</h6>
<div class="mb-2">
<label class="form-label small">LDAP Group Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
</div>
<div class="mb-2">
<label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option>
<option value="Admin">Admin</option>
<option value="Design">Design</option>
<option value="Deployment">Deployment</option>
</select>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@if (IsEditMode && _formRole.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Site Scope Rules</h6>
@if (_scopeRules.Count > 0)
{
<table class="table table-sm table-striped table-hover mb-3">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Site ID</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var rule in _scopeRules)
{
<tr>
<td>@rule.Id</td>
<td>@rule.SiteId</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScopeRule(rule)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-muted small mb-3">All sites (no restrictions)</p>
}
<div class="mb-2">
<label class="form-label small">Site ID</label>
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" />
</div>
@if (_scopeRuleError != null)
{
<div class="text-danger small mt-2">@_scopeRuleError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm" @onclick="AddScopeRule">Add</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => Id.HasValue;
private LdapGroupMapping? _editingMapping;
private string _formGroupName = string.Empty;
private string _formRole = string.Empty;
private string? _formError;
private List<SiteScopeRule> _scopeRules = new();
private int _scopeRuleSiteId;
private string? _scopeRuleError;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value);
if (_editingMapping != null)
{
_formGroupName = _editingMapping.LdapGroupName;
_formRole = _editingMapping.Role;
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
}
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
private async Task SaveMapping()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formGroupName))
{
_formError = "LDAP Group Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_formRole))
{
_formError = "Role is required.";
return;
}
try
{
if (_editingMapping != null)
{
_editingMapping.LdapGroupName = _formGroupName.Trim();
_editingMapping.Role = _formRole;
await SecurityRepository.UpdateMappingAsync(_editingMapping);
}
else
{
var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole);
await SecurityRepository.AddMappingAsync(mapping);
}
await SecurityRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task AddScopeRule()
{
_scopeRuleError = null;
if (_scopeRuleSiteId <= 0)
{
_scopeRuleError = "Site ID must be a positive number.";
return;
}
try
{
var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId };
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
_scopeRuleSiteId = 0;
}
catch (Exception ex)
{
_scopeRuleError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteScopeRule(SiteScopeRule rule)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete scope rule for Site {rule.SiteId}? This cannot be undone.",
"Delete Scope Rule");
if (!confirmed) return;
try
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
await SecurityRepository.SaveChangesAsync();
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList();
}
catch (Exception ex)
{
_scopeRuleError = $"Delete failed: {ex.Message}";
}
}
}

View File

@@ -4,11 +4,12 @@
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Security
@inject ISecurityRepository SecurityRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">LDAP Group Mappings</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Mapping</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/ldap-mappings/create")'>Add Mapping</button>
</div>
@if (_loading)
@@ -21,39 +22,6 @@
}
else
{
@* Add / Edit form *@
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingMapping == null ? "Add New Mapping" : "Edit Mapping")</h6>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">LDAP Group Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
</div>
<div class="col-md-3">
<label class="form-label small">Role</label>
<select class="form-select form-select-sm" @bind="_formRole">
<option value="">Select role...</option>
<option value="Admin">Admin</option>
<option value="Design">Design</option>
<option value="Deployment">Deployment</option>
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
@* Mappings table *@
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
@@ -95,13 +63,12 @@
}
@if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
{
<button class="btn btn-outline-info btn-sm ms-2 py-0 px-1"
@onclick="() => ShowScopeRuleForm(mapping.Id)">+ Scope</button>
<span class="text-muted small ms-2">(manage on edit page)</span>
}
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditMapping(mapping)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteMapping(mapping.Id)">Delete</button>
</td>
@@ -110,29 +77,6 @@
</tbody>
</table>
@* Scope rule form *@
@if (_showScopeRuleForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Add Site Scope Rule (Mapping #@_scopeRuleMappingId)</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Site ID</label>
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveScopeRule">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScopeRuleForm">Cancel</button>
</div>
</div>
@if (_scopeRuleError != null)
{
<div class="text-danger small mt-1">@_scopeRuleError</div>
}
</div>
</div>
}
}
</div>
@@ -142,19 +86,6 @@
private bool _loading = true;
private string? _errorMessage;
// Mapping form state
private bool _showForm;
private LdapGroupMapping? _editingMapping;
private string _formGroupName = string.Empty;
private string _formRole = string.Empty;
private string? _formError;
// Scope rule form state
private bool _showScopeRuleForm;
private int _scopeRuleMappingId;
private int _scopeRuleSiteId;
private string? _scopeRuleError;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
@@ -184,71 +115,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingMapping = null;
_formGroupName = string.Empty;
_formRole = string.Empty;
_formError = null;
_showForm = true;
}
private void EditMapping(LdapGroupMapping mapping)
{
_editingMapping = mapping;
_formGroupName = mapping.LdapGroupName;
_formRole = mapping.Role;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingMapping = null;
_formError = null;
}
private async Task SaveMapping()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formGroupName))
{
_formError = "LDAP Group Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_formRole))
{
_formError = "Role is required.";
return;
}
try
{
if (_editingMapping != null)
{
_editingMapping.LdapGroupName = _formGroupName.Trim();
_editingMapping.Role = _formRole;
await SecurityRepository.UpdateMappingAsync(_editingMapping);
}
else
{
var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole);
await SecurityRepository.AddMappingAsync(mapping);
}
await SecurityRepository.SaveChangesAsync();
_showForm = false;
_editingMapping = null;
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteMapping(int id)
{
try
@@ -269,45 +135,4 @@
}
}
private void ShowScopeRuleForm(int mappingId)
{
_scopeRuleMappingId = mappingId;
_scopeRuleSiteId = 0;
_scopeRuleError = null;
_showScopeRuleForm = true;
}
private void CancelScopeRuleForm()
{
_showScopeRuleForm = false;
_scopeRuleError = null;
}
private async Task SaveScopeRule()
{
_scopeRuleError = null;
if (_scopeRuleSiteId <= 0)
{
_scopeRuleError = "Site ID must be a positive number.";
return;
}
try
{
var rule = new SiteScopeRule
{
LdapGroupMappingId = _scopeRuleMappingId,
SiteId = _scopeRuleSiteId
};
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
_showScopeRuleForm = false;
await LoadDataAsync();
}
catch (Exception ex)
{
_scopeRuleError = $"Save failed: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,163 @@
@page "/admin/sites/create"
@page "/admin/sites/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Communication
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject CommunicationService CommunicationService
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">
&larr; Back
</button>
</div>
<ToastNotification @ref="_toast" />
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
<div class="mb-2">
<label class="form-label small">Identifier</label>
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
disabled="@IsEditMode" />
</div>
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" />
</div>
<div class="mb-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
</div>
<h6 class="text-muted border-bottom pb-1">Node A</h6>
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="mb-3">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
<h6 class="text-muted border-bottom pb-1">Node B</h6>
<div class="mb-2">
<label class="form-label small">Akka Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="mb-3">
<label class="form-label small">gRPC Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool IsEditMode => Id.HasValue;
private Site? _editingSite;
private string _formName = string.Empty;
private string _formIdentifier = string.Empty;
private string? _formDescription;
private string? _formNodeAAddress;
private string? _formNodeBAddress;
private string? _formGrpcNodeAAddress;
private string? _formGrpcNodeBAddress;
private string? _formError;
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_editingSite = await SiteRepository.GetSiteByIdAsync(Id.Value);
if (_editingSite != null)
{
_formName = _editingSite.Name;
_formIdentifier = _editingSite.SiteIdentifier;
_formDescription = _editingSite.Description;
_formNodeAAddress = _editingSite.NodeAAddress;
_formNodeBAddress = _editingSite.NodeBAddress;
_formGrpcNodeAAddress = _editingSite.GrpcNodeAAddress;
_formGrpcNodeBAddress = _editingSite.GrpcNodeBAddress;
}
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/admin/sites");
}
private async Task SaveSite()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName))
{
_formError = "Name is required.";
return;
}
try
{
if (_editingSite != null)
{
_editingSite.Name = _formName.Trim();
_editingSite.Description = _formDescription?.Trim();
_editingSite.NodeAAddress = _formNodeAAddress?.Trim();
_editingSite.NodeBAddress = _formNodeBAddress?.Trim();
_editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim();
_editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim();
await SiteRepository.UpdateSiteAsync(_editingSite);
}
else
{
if (string.IsNullOrWhiteSpace(_formIdentifier))
{
_formError = "Identifier is required.";
return;
}
var site = new Site(_formName.Trim(), _formIdentifier.Trim())
{
Description = _formDescription?.Trim(),
NodeAAddress = _formNodeAAddress?.Trim(),
NodeBAddress = _formNodeBAddress?.Trim(),
GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(),
GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim()
};
await SiteRepository.AddSiteAsync(site);
}
await SiteRepository.SaveChangesAsync();
CommunicationService.RefreshSiteAddresses();
NavigationManager.NavigateTo("/admin/sites");
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
}

View File

@@ -9,6 +9,7 @@
@inject ArtifactDeploymentService ArtifactDeploymentService
@inject CommunicationService CommunicationService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -22,7 +23,7 @@
}
Deploy Artifacts to All Sites
</button>
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Site</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>Add Site</button>
</div>
</div>
@@ -39,62 +40,6 @@
}
else
{
@if (_showForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">@(_editingSite == null ? "Add New Site" : "Edit Site")</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="_formName" />
</div>
<div class="col-md-3">
<label class="form-label small">Identifier</label>
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
disabled="@(_editingSite != null)" />
</div>
<div class="col-md-3">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-md-6">
<label class="form-label small">Node A Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
<div class="col-md-6">
<label class="form-label small">Node B Address (optional)</label>
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
placeholder="akka.tcp://scadalink@host:port/user/site-communication" />
</div>
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-md-6">
<label class="form-label small">gRPC Node A Address</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
placeholder="http://host:8083" />
</div>
<div class="col-md-6">
<label class="form-label small">gRPC Node B Address (optional)</label>
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
placeholder="http://host:8083" />
</div>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-1">@_formError</div>
}
</div>
</div>
}
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
@@ -146,7 +91,7 @@
</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick="() => EditSite(site)">Edit</button>
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>Edit</button>
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
@onclick="() => DeployArtifacts(site)"
disabled="@_deploying">Deploy Artifacts</button>
@@ -172,17 +117,6 @@
private bool _loading = true;
private string? _errorMessage;
private bool _showForm;
private Site? _editingSite;
private string _formName = string.Empty;
private string _formIdentifier = string.Empty;
private string? _formDescription;
private string? _formNodeAAddress;
private string? _formNodeBAddress;
private string? _formGrpcNodeAAddress;
private string? _formGrpcNodeBAddress;
private string? _formError;
private bool _deploying;
private ToastNotification _toast = default!;
@@ -217,94 +151,6 @@
_loading = false;
}
private void ShowAddForm()
{
_editingSite = null;
_formName = string.Empty;
_formIdentifier = string.Empty;
_formDescription = null;
_formNodeAAddress = null;
_formNodeBAddress = null;
_formGrpcNodeAAddress = null;
_formGrpcNodeBAddress = null;
_formError = null;
_showForm = true;
}
private void EditSite(Site site)
{
_editingSite = site;
_formName = site.Name;
_formIdentifier = site.SiteIdentifier;
_formDescription = site.Description;
_formNodeAAddress = site.NodeAAddress;
_formNodeBAddress = site.NodeBAddress;
_formGrpcNodeAAddress = site.GrpcNodeAAddress;
_formGrpcNodeBAddress = site.GrpcNodeBAddress;
_formError = null;
_showForm = true;
}
private void CancelForm()
{
_showForm = false;
_editingSite = null;
_formError = null;
}
private async Task SaveSite()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_formName))
{
_formError = "Name is required.";
return;
}
try
{
if (_editingSite != null)
{
_editingSite.Name = _formName.Trim();
_editingSite.Description = _formDescription?.Trim();
_editingSite.NodeAAddress = _formNodeAAddress?.Trim();
_editingSite.NodeBAddress = _formNodeBAddress?.Trim();
_editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim();
_editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim();
await SiteRepository.UpdateSiteAsync(_editingSite);
}
else
{
if (string.IsNullOrWhiteSpace(_formIdentifier))
{
_formError = "Identifier is required.";
return;
}
var site = new Site(_formName.Trim(), _formIdentifier.Trim())
{
Description = _formDescription?.Trim(),
NodeAAddress = _formNodeAAddress?.Trim(),
NodeBAddress = _formNodeBAddress?.Trim(),
GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(),
GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim()
};
await SiteRepository.AddSiteAsync(site);
}
await SiteRepository.SaveChangesAsync();
CommunicationService.RefreshSiteAddresses();
_showForm = false;
_editingSite = null;
_toast.ShowSuccess(_editingSite == null ? "Site created." : "Site updated.");
await LoadDataAsync();
}
catch (Exception ex)
{
_formError = $"Save failed: {ex.Message}";
}
}
private async Task DeleteSite(Site site)
{
var confirmed = await _confirmDialog.ShowAsync(

View File

@@ -0,0 +1,143 @@
@page "/deployment/instances/create"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<a href="/deployment/instances" class="btn btn-outline-secondary btn-sm me-3">&larr; Back</a>
<h4 class="mb-0">Create Instance</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" placeholder="e.g. Motor-1" />
</div>
<div class="mb-2">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" @bind="_createTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_createSiteId">
<option value="0">Select site...</option>
@foreach (var s in _sites)
{
<option value="@s.Id">@s.Name</option>
}
</select>
</div>
<div class="mb-2">
<label class="form-label small">Area</label>
<select class="form-select form-select-sm" @bind="_createAreaId">
<option value="0">No area</option>
@foreach (var a in _allAreas.Where(a => a.SiteId == _createSiteId))
{
<option value="@a.Id">@a.Name</option>
}
</select>
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
private List<Site> _sites = new();
private List<Template> _templates = new();
private List<Area> _allAreas = new();
private bool _loading = true;
private string _createName = string.Empty;
private int _createTemplateId;
private int _createSiteId;
private int _createAreaId;
private string? _formError;
protected override async Task OnInitializedAsync()
{
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_allAreas.Clear();
foreach (var site in _sites)
{
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
_allAreas.AddRange(areas);
}
}
catch (Exception ex)
{
_formError = $"Failed to load data: {ex.Message}";
}
_loading = false;
}
private async Task CreateInstance()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Instance name is required."; return; }
if (_createTemplateId == 0) { _formError = "Select a template."; return; }
if (_createSiteId == 0) { _formError = "Select a site."; return; }
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/deployment/instances");
}
else
{
_formError = result.Error;
}
}
catch (Exception ex)
{
_formError = $"Create failed: {ex.Message}";
}
}
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
}

View File

@@ -15,11 +15,12 @@
@inject DeploymentService DeploymentService
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Instances</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowCreateForm">Create Instance</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>Create Instance</button>
</div>
<ToastNotification @ref="_toast" />
@@ -35,59 +36,6 @@
}
else
{
@if (_showCreateForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Create Instance</h6>
<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="_createName" placeholder="e.g. Motor-1" />
</div>
<div class="col-md-3">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" @bind="_createTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_createSiteId">
<option value="0">Select site...</option>
@foreach (var s in _sites)
{
<option value="@s.Id">@s.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Area</label>
<select class="form-select form-select-sm" @bind="_createAreaId">
<option value="0">No area</option>
@foreach (var a in _allAreas.Where(a => a.SiteId == _createSiteId))
{
<option value="@a.Id">@a.Name</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">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>
}
@* Filters *@
<div class="row mb-3 g-2">
<div class="col-md-2">
@@ -614,53 +562,6 @@
_actionInProgress = false;
}
// Create instance form
private bool _showCreateForm;
private string _createName = string.Empty;
private int _createTemplateId;
private int _createSiteId;
private int _createAreaId;
private string? _createError;
private void ShowCreateForm()
{
_createName = string.Empty;
_createTemplateId = 0;
_createSiteId = 0;
_createAreaId = 0;
_createError = null;
_showCreateForm = true;
}
private async Task CreateInstance()
{
_createError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _createError = "Instance name is required."; return; }
if (_createTemplateId == 0) { _createError = "Select a template."; return; }
if (_createSiteId == 0) { _createError = "Select a site."; return; }
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
if (result.IsSuccess)
{
_showCreateForm = false;
_toast.ShowSuccess($"Instance '{_createName}' created.");
await LoadDataAsync();
}
else
{
_createError = result.Error;
}
}
catch (Exception ex)
{
_createError = $"Create failed: {ex.Message}";
}
}
// Override state
private int _overrideInstanceId;
private List<TemplateAttribute> _overrideAttrs = new();

View File

@@ -0,0 +1,126 @@
@page "/design/api-methods/create"
@page "/design/api-methods/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.InboundApi
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit API Method" : "Add API Method")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" disabled="@Id.HasValue" />
</div>
<div class="mb-3">
<label class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Params (JSON)</label>
<input type="text" class="form-control" @bind="_params" />
</div>
<div class="mb-3">
<label class="form-label">Returns (JSON)</label>
<input type="text" class="form-control" @bind="_returns" />
</div>
<div class="mb-3">
<label class="form-label">Script</label>
<textarea class="form-control font-monospace" rows="5" @bind="_script" style="font-size: 0.85rem;"></textarea>
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _script = "";
private int _timeoutSeconds = 30;
private string? _params, _returns;
private string? _formError;
private ApiMethod? _existing;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await InboundApiRepository.GetApiMethodByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_script = _existing.Script;
_timeoutSeconds = _existing.TimeoutSeconds;
_params = _existing.ParameterDefinitions;
_returns = _existing.ReturnDefinition;
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_script))
{
_formError = "Name and script required.";
return;
}
try
{
if (_existing != null)
{
_existing.Script = _script;
_existing.TimeoutSeconds = _timeoutSeconds;
_existing.ParameterDefinitions = _params?.Trim();
_existing.ReturnDefinition = _returns?.Trim();
await InboundApiRepository.UpdateApiMethodAsync(_existing);
}
else
{
var m = new ApiMethod(_name.Trim(), _script)
{
TimeoutSeconds = _timeoutSeconds,
ParameterDefinitions = _params?.Trim(),
ReturnDefinition = _returns?.Trim()
};
await InboundApiRepository.AddApiMethodAsync(m);
}
await InboundApiRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}

View File

@@ -0,0 +1,120 @@
@page "/design/db-connections/create"
@page "/design/db-connections/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.ExternalSystems
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IExternalSystemRepository ExternalSystemRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit Database Connection" : "Add Database Connection")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
<div class="mb-3">
<label class="form-label">Connection String</label>
<input type="text" class="form-control" @bind="_connectionString" />
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<input type="number" class="form-control" @bind="_maxRetries" min="0" />
</div>
<div class="mb-3">
<label class="form-label">Retry Delay (seconds)</label>
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _connectionString = "";
private int _maxRetries = 3;
private int _retryDelaySeconds = 5;
private string? _formError;
private DatabaseConnectionDefinition? _existing;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await ExternalSystemRepository.GetDatabaseConnectionByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_connectionString = _existing.ConnectionString;
_maxRetries = _existing.MaxRetries;
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_connectionString))
{
_formError = "Name and connection string required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.ConnectionString = _connectionString.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_existing);
}
else
{
var dc = new DatabaseConnectionDefinition(_name.Trim(), _connectionString.Trim())
{
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
};
await ExternalSystemRepository.AddDatabaseConnectionAsync(dc);
}
await ExternalSystemRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}

View File

@@ -0,0 +1,137 @@
@page "/design/external-systems/create"
@page "/design/external-systems/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.ExternalSystems
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IExternalSystemRepository ExternalSystemRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
<div class="mb-3">
<label class="form-label">Endpoint URL</label>
<input type="text" class="form-control" @bind="_endpointUrl" />
</div>
<div class="mb-3">
<label class="form-label">Auth Type</label>
<select class="form-select" @bind="_authType">
<option>ApiKey</option>
<option>BasicAuth</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Auth Config (JSON)</label>
<input type="text" class="form-control" @bind="_authConfig" />
</div>
<div class="mb-3">
<label class="form-label">Max Retries</label>
<input type="number" class="form-control" @bind="_maxRetries" min="0" />
</div>
<div class="mb-3">
<label class="form-label">Retry Delay (seconds)</label>
<input type="number" class="form-control" @bind="_retryDelaySeconds" min="0" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "", _endpointUrl = "", _authType = "ApiKey";
private string? _authConfig;
private int _maxRetries = 3;
private int _retryDelaySeconds = 5;
private string? _formError;
private ExternalSystemDefinition? _existing;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await ExternalSystemRepository.GetExternalSystemByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
_endpointUrl = _existing.EndpointUrl;
_authType = _existing.AuthType;
_authConfig = _existing.AuthConfiguration;
_maxRetries = _existing.MaxRetries;
_retryDelaySeconds = (int)_existing.RetryDelay.TotalSeconds;
}
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_endpointUrl))
{
_formError = "Name and URL required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.EndpointUrl = _endpointUrl.Trim();
_existing.AuthType = _authType;
_existing.AuthConfiguration = _authConfig?.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await ExternalSystemRepository.UpdateExternalSystemAsync(_existing);
}
else
{
var es = new ExternalSystemDefinition(_name.Trim(), _endpointUrl.Trim(), _authType)
{
AuthConfiguration = _authConfig?.Trim(),
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
};
await ExternalSystemRepository.AddExternalSystemAsync(es);
}
await ExternalSystemRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}

View File

@@ -8,6 +8,7 @@
@inject IExternalSystemRepository ExternalSystemRepository
@inject INotificationRepository NotificationRepository
@inject IInboundApiRepository InboundApiRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<h4 class="mb-3">Integration Definitions</h4>
@@ -62,22 +63,9 @@
// External Systems
private List<ExternalSystemDefinition> _externalSystems = new();
private bool _showExtSysForm;
private ExternalSystemDefinition? _editingExtSys;
private string _extSysName = "", _extSysUrl = "", _extSysAuth = "ApiKey";
private string? _extSysAuthConfig;
private int _extSysMaxRetries = 3;
private int _extSysRetryDelaySeconds = 5;
private string? _extSysFormError;
// Database Connections
private List<DatabaseConnectionDefinition> _dbConnections = new();
private bool _showDbConnForm;
private DatabaseConnectionDefinition? _editingDbConn;
private string _dbConnName = "", _dbConnString = "";
private int _dbConnMaxRetries = 3;
private int _dbConnRetryDelaySeconds = 5;
private string? _dbConnFormError;
// SMTP Configuration
private List<SmtpConfiguration> _smtpConfigs = new();
@@ -92,26 +80,10 @@
// 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!;
@@ -150,28 +122,9 @@
{
<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>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/external-systems/create")'>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-2"><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-1"><label class="form-label small">Max Retries</label><input type="number" class="form-control form-control-sm" @bind="_extSysMaxRetries" min="0" /></div>
<div class="col-md-1"><label class="form-label small">Retry Delay (s)</label><input type="number" class="form-control form-control-sm" @bind="_extSysRetryDelaySeconds" min="0" /></div>
<div class="col-md-1">
<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>Retries</th><th>Delay</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@@ -181,7 +134,7 @@
<td>@es.Name</td><td class="small">@es.EndpointUrl</td><td><span class="badge bg-secondary">@es.AuthType</span></td>
<td class="small">@es.MaxRetries</td><td class="small">@es.RetryDelay.TotalSeconds s</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; _extSysMaxRetries = es.MaxRetries; _extSysRetryDelaySeconds = (int)es.RetryDelay.TotalSeconds; _showExtSysForm = true; }">Edit</button>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick='() => NavigationManager.NavigateTo($"/design/external-systems/{es.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteExtSys(es)">Delete</button>
</td>
</tr>
@@ -190,31 +143,6 @@
</table>
};
private void ShowExtSysAddForm()
{
_showExtSysForm = true;
_editingExtSys = null;
_extSysName = _extSysUrl = string.Empty;
_extSysAuth = "ApiKey";
_extSysAuthConfig = null;
_extSysMaxRetries = 3;
_extSysRetryDelaySeconds = 5;
_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(); _editingExtSys.MaxRetries = _extSysMaxRetries; _editingExtSys.RetryDelay = TimeSpan.FromSeconds(_extSysRetryDelaySeconds); await ExternalSystemRepository.UpdateExternalSystemAsync(_editingExtSys); }
else { var es = new ExternalSystemDefinition(_extSysName.Trim(), _extSysUrl.Trim(), _extSysAuth) { AuthConfiguration = _extSysAuthConfig?.Trim(), MaxRetries = _extSysMaxRetries, RetryDelay = TimeSpan.FromSeconds(_extSysRetryDelaySeconds) }; 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;
@@ -227,25 +155,9 @@
{
<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; _dbConnMaxRetries = 3; _dbConnRetryDelaySeconds = 5; _dbConnFormError = null; }">Add</button>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/db-connections/create")'>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-4"><label class="form-label small">Connection String</label><input type="text" class="form-control form-control-sm" @bind="_dbConnString" /></div>
<div class="col-md-1"><label class="form-label small">Max Retries</label><input type="number" class="form-control form-control-sm" @bind="_dbConnMaxRetries" min="0" /></div>
<div class="col-md-1"><label class="form-label small">Retry Delay (s)</label><input type="number" class="form-control form-control-sm" @bind="_dbConnRetryDelaySeconds" min="0" /></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>Retries</th><th>Delay</th><th style="width:120px;">Actions</th></tr></thead>
<tbody>
@@ -255,7 +167,7 @@
<td>@dc.Name</td><td class="small text-muted text-truncate" style="max-width:400px;">@dc.ConnectionString</td>
<td class="small">@dc.MaxRetries</td><td class="small">@dc.RetryDelay.TotalSeconds s</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; _dbConnMaxRetries = dc.MaxRetries; _dbConnRetryDelaySeconds = (int)dc.RetryDelay.TotalSeconds; _showDbConnForm = true; }">Edit</button>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1" @onclick='() => NavigationManager.NavigateTo($"/design/db-connections/{dc.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteDbConn(dc)">Delete</button>
</td>
</tr>
@@ -264,19 +176,6 @@
</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(); _editingDbConn.MaxRetries = _dbConnMaxRetries; _editingDbConn.RetryDelay = TimeSpan.FromSeconds(_dbConnRetryDelaySeconds); await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_editingDbConn); }
else { var dc = new DatabaseConnectionDefinition(_dbConnName.Trim(), _dbConnString.Trim()) { MaxRetries = _dbConnMaxRetries, RetryDelay = TimeSpan.FromSeconds(_dbConnRetryDelaySeconds) }; 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;
@@ -289,44 +188,16 @@
{
<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>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/notification-lists/create")'>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-primary btn-sm py-0 px-1 me-1" @onclick='() => NavigationManager.NavigateTo($"/design/notification-lists/{list.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteNotifList(list)">Delete</button>
</div>
</div>
@@ -344,7 +215,6 @@
{
<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>
}
}
@@ -353,19 +223,6 @@
}
};
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;
@@ -373,54 +230,14 @@
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>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/api-methods/create")'>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>
@@ -431,7 +248,7 @@
<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-primary btn-sm py-0 px-1 me-1" @onclick='() => NavigationManager.NavigateTo($"/design/api-methods/{m.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteApiMethod(m)">Delete</button>
</td>
</tr>
@@ -440,19 +257,6 @@
</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;

View File

@@ -0,0 +1,190 @@
@page "/design/notification-lists/create"
@page "/design/notification-lists/{Id:int}/edit"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Notifications
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-3">@(Id.HasValue ? "Edit Notification List" : "Add Notification List")</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="_name" />
</div>
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="Save">Save</button>
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
@if (Id.HasValue)
{
<h5 class="mt-4 mb-3">Recipients</h5>
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_recipientName" />
</div>
<div class="mb-2">
<label class="form-label small">Email</label>
<input type="email" class="form-control form-control-sm" @bind="_recipientEmail" />
</div>
@if (_recipientFormError != null)
{
<div class="text-danger small mt-2">@_recipientFormError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm" @onclick="SaveRecipient">Add</button>
</div>
</div>
</div>
<table class="table table-sm table-striped">
<thead class="table-dark">
<tr>
<th>Name</th>
<th>Email</th>
<th style="width:80px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_recipients.Count == 0)
{
<tr><td colspan="3" class="text-muted small">No recipients.</td></tr>
}
else
{
@foreach (var r in _recipients)
{
<tr>
<td>@r.Name</td>
<td>@r.EmailAddress</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
</td>
</tr>
}
}
</tbody>
</table>
}
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading = true;
private string _name = "";
private string? _formError;
private NotificationList? _existing;
// Recipients
private List<NotificationRecipient> _recipients = new();
private string _recipientName = "", _recipientEmail = "";
private string? _recipientFormError;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
try
{
_existing = await NotificationRepository.GetNotificationListByIdAsync(Id.Value);
if (_existing != null)
{
_name = _existing.Name;
}
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
catch (Exception ex) { _formError = ex.Message; }
}
_loading = false;
}
private async Task Save()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_name))
{
_formError = "Name required.";
return;
}
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
await NotificationRepository.UpdateNotificationListAsync(_existing);
}
else
{
var nl = new NotificationList(_name.Trim());
await NotificationRepository.AddNotificationListAsync(nl);
}
await NotificationRepository.SaveChangesAsync();
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = 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 = Id!.Value
};
await NotificationRepository.AddRecipientAsync(r);
await NotificationRepository.SaveChangesAsync();
_recipientName = _recipientEmail = string.Empty;
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
private async Task DeleteRecipient(NotificationRecipient r)
{
try
{
await NotificationRepository.DeleteRecipientAsync(r.Id);
await NotificationRepository.SaveChangesAsync();
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id!.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
}
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
}

View File

@@ -0,0 +1,194 @@
@page "/design/shared-scripts/create"
@page "/design/shared-scripts/{Id:int}/edit"
@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
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">&larr; Back</button>
<h4 class="mb-0">@(Id.HasValue ? $"Edit Shared Script: {_formName}" : "New Shared Script")</h4>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card mb-3">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName"
disabled="@(Id.HasValue)" />
</div>
<div class="mb-2">
<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="mb-2">
<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 class="mb-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>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
@if (_syntaxCheckResult != null)
{
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
}
<div class="mt-3">
<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="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
[Parameter] public int? Id { get; set; }
private bool _loading;
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 async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
_loading = true;
try
{
var scripts = await SharedScriptService.GetAllSharedScriptsAsync();
var script = scripts.FirstOrDefault(s => s.Id == Id.Value);
if (script != null)
{
_formName = script.Name;
_formCode = script.Code;
_formParameters = script.ParameterDefinitions;
_formReturn = script.ReturnDefinition;
}
else
{
_formError = $"Shared script with ID {Id.Value} not found.";
}
}
catch (Exception ex)
{
_formError = $"Failed to load script: {ex.Message}";
}
_loading = false;
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
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 (Id.HasValue)
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.UpdateSharedScriptAsync(
Id.Value, _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
else
{
_formError = result.Error;
}
}
else
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.CreateSharedScriptAsync(
_formName.Trim(), _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/shared-scripts");
}
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;
}
}

View File

@@ -7,11 +7,12 @@
@inject ITemplateEngineRepository TemplateEngineRepository
@inject SharedScriptService SharedScriptService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<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>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/shared-scripts/create")'>New Script</button>
</div>
<ToastNotification @ref="_toast" />
@@ -27,50 +28,6 @@
}
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>
@@ -101,7 +58,7 @@
<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>
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{script.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScript(script)">Delete</button>
</td>
@@ -123,16 +80,6 @@
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!;
@@ -156,118 +103,6 @@
_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 user = await GetCurrentUserAsync();
var result = await SharedScriptService.UpdateSharedScriptAsync(
_editingScript.Id, _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
if (result.IsSuccess)
{
_showForm = false;
_toast.ShowSuccess($"Script '{_editingScript.Name}' updated.");
await LoadDataAsync();
}
else
{
_formError = result.Error;
}
}
else
{
var user = await GetCurrentUserAsync();
var result = await SharedScriptService.CreateSharedScriptAsync(
_formName.Trim(), _formCode, _formParameters?.Trim(), _formReturn?.Trim(), user);
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(

View File

@@ -0,0 +1,117 @@
@page "/design/templates/create"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.TemplateEngine
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">&larr; Back</button>
</div>
<h4 class="mb-3">Create Template</h4>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else
{
<div class="card">
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" />
</div>
<div class="mb-2">
<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="mb-2">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_createDescription" />
</div>
@if (_formError != null)
{
<div class="text-danger small mt-2">@_formError</div>
}
<div class="mt-3">
<button class="btn btn-success btn-sm me-1" @onclick="CreateTemplate">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
</div>
</div>
</div>
}
</div>
@code {
private List<Template> _templates = new();
private bool _loading = true;
private string _createName = string.Empty;
private int _createParentId;
private string? _createDescription;
private string? _formError;
protected override async Task OnInitializedAsync()
{
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
}
catch (Exception ex)
{
_formError = $"Failed to load templates: {ex.Message}";
}
_loading = false;
}
private async Task CreateTemplate()
{
_formError = null;
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Name is required."; return; }
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
_createName.Trim(), _createDescription?.Trim(),
_createParentId == 0 ? null : _createParentId, user);
if (result.IsSuccess)
{
NavigationManager.NavigateTo("/design/templates");
}
else
{
_formError = result.Error;
}
}
catch (Exception ex)
{
_formError = ex.Message;
}
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/templates");
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
}

View File

@@ -11,6 +11,7 @@
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
@@ -29,46 +30,9 @@
@* 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>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>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">
@@ -254,13 +218,6 @@
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;
@@ -377,44 +334,6 @@
_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 user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync(
_createName.Trim(), _createDescription?.Trim(),
_createParentId == 0 ? null : _createParentId, user);
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(