@@ -146,7 +91,7 @@
EditSite(site)">Edit
+ @onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>Edit
DeployArtifacts(site)"
disabled="@_deploying">Deploy Artifacts
@@ -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(
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
new file mode 100644
index 0000000..358a049
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
@@ -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
+
+
+
+
+ @if (_loading)
+ {
+
+ }
+ else
+ {
+
+
+
+ Instance Name
+
+
+
+ Template
+
+ Select template...
+ @foreach (var t in _templates)
+ {
+ @t.Name
+ }
+
+
+
+ Site
+
+ Select site...
+ @foreach (var s in _sites)
+ {
+ @s.Name
+ }
+
+
+
+ Area
+
+ No area
+ @foreach (var a in _allAreas.Where(a => a.SiteId == _createSiteId))
+ {
+ @a.Name
+ }
+
+
+ @if (_formError != null)
+ {
+
@_formError
+ }
+
+ Create
+ Cancel
+
+
+
+ }
+
+
+@code {
+ private List _sites = new();
+ private List _templates = new();
+ private List _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 GetCurrentUserAsync()
+ {
+ var authState = await AuthStateProvider.GetAuthenticationStateAsync();
+ return authState.User.FindFirst("Username")?.Value ?? "unknown";
+ }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor
index 1864ec6..db74583 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor
@@ -15,11 +15,12 @@
@inject DeploymentService DeploymentService
@inject InstanceService InstanceService
@inject AuthenticationStateProvider AuthStateProvider
+@inject NavigationManager NavigationManager
Instances
- Create Instance
+ NavigationManager.NavigateTo("/deployment/instances/create")'>Create Instance
@@ -35,59 +36,6 @@
}
else
{
- @if (_showCreateForm)
- {
-
-
-
Create Instance
-
-
- Instance Name
-
-
-
- Template
-
- Select template...
- @foreach (var t in _templates)
- {
- @t.Name
- }
-
-
-
- Site
-
- Select site...
- @foreach (var s in _sites)
- {
- @s.Name
- }
-
-
-
- Area
-
- No area
- @foreach (var a in _allAreas.Where(a => a.SiteId == _createSiteId))
- {
- @a.Name
- }
-
-
-
- Create
- _showCreateForm = false">Cancel
-
-
- @if (_createError != null)
- {
-
@_createError
- }
-
-
- }
-
@* Filters *@
@@ -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
_overrideAttrs = new();
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor
new file mode 100644
index 0000000..5e50f84
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor
@@ -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
+
+
+
← Back
+
+
@(Id.HasValue ? "Edit API Method" : "Add API Method")
+
+ @if (_loading)
+ {
+
+ }
+ else
+ {
+
+
+
+ Name
+
+
+
+ Timeout (seconds)
+
+
+
+ Params (JSON)
+
+
+
+ Returns (JSON)
+
+
+
+ Script
+
+
+
+ @if (_formError != null)
+ {
+
@_formError
+ }
+
+
+ Save
+ Cancel
+
+
+
+ }
+
+
+@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");
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/DbConnectionForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/DbConnectionForm.razor
new file mode 100644
index 0000000..98e7099
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/DbConnectionForm.razor
@@ -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
+
+
+
← Back
+
+
@(Id.HasValue ? "Edit Database Connection" : "Add Database Connection")
+
+ @if (_loading)
+ {
+
+ }
+ else
+ {
+
+
+
+ Name
+
+
+
+ Connection String
+
+
+
+ Max Retries
+
+
+
+ Retry Delay (seconds)
+
+
+
+ @if (_formError != null)
+ {
+
@_formError
+ }
+
+
+ Save
+ Cancel
+
+
+
+ }
+
+
+@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");
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor
new file mode 100644
index 0000000..6136f52
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor
@@ -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
+
+
+
← Back
+
+
@(Id.HasValue ? "Edit External System" : "Add External System")
+
+ @if (_loading)
+ {
+
+ }
+ else
+ {
+
+
+
+ Name
+
+
+
+ Endpoint URL
+
+
+
+ Auth Type
+
+ ApiKey
+ BasicAuth
+
+
+
+ Auth Config (JSON)
+
+
+
+ Max Retries
+
+
+
+ Retry Delay (seconds)
+
+
+
+ @if (_formError != null)
+ {
+
@_formError
+ }
+
+
+ Save
+ Cancel
+
+
+
+ }
+
+
+@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");
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor
index aaaec92..3250b14 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor
@@ -8,6 +8,7 @@
@inject IExternalSystemRepository ExternalSystemRepository
@inject INotificationRepository NotificationRepository
@inject IInboundApiRepository InboundApiRepository
+@inject NavigationManager NavigationManager
Integration Definitions
@@ -62,22 +63,9 @@
// External Systems
private List
_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 _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 _smtpConfigs = new();
@@ -92,26 +80,10 @@
// Notification Lists
private List _notificationLists = new();
- private bool _showNotifForm;
- private NotificationList? _editingNotifList;
- private string _notifName = "";
- private string? _notifFormError;
-
- // Notification Recipients
private Dictionary> _recipients = new();
- private bool _showRecipientForm;
- private int _recipientListId;
- private string _recipientName = "", _recipientEmail = "";
- private string? _recipientFormError;
// Inbound API Methods
private List _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 @@
{
External Systems
- Add
+ NavigationManager.NavigateTo("/design/external-systems/create")'>Add
- @if (_showExtSysForm)
- {
-
-
-
Name
-
Endpoint URL
-
Auth Type
- ApiKey BasicAuth
-
Auth Config (JSON)
-
Max Retries
-
Retry Delay (s)
-
- Save
- _showExtSysForm = false">Cancel
-
- @if (_extSysFormError != null) {
@_extSysFormError
}
-
- }
-
Name URL Auth Retries Delay Actions
@@ -181,7 +134,7 @@
@es.Name @es.EndpointUrl @es.AuthType
@es.MaxRetries @es.RetryDelay.TotalSeconds s
- { _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
+ NavigationManager.NavigateTo($"/design/external-systems/{es.Id}/edit")'>Edit
DeleteExtSys(es)">Delete
@@ -190,31 +143,6 @@
};
- 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 @@
{
Database Connections
- { _showDbConnForm = true; _editingDbConn = null; _dbConnName = _dbConnString = string.Empty; _dbConnMaxRetries = 3; _dbConnRetryDelaySeconds = 5; _dbConnFormError = null; }">Add
+ NavigationManager.NavigateTo("/design/db-connections/create")'>Add
- @if (_showDbConnForm)
- {
-
-
- @if (_dbConnFormError != null) {
@_dbConnFormError
}
-
- }
-
Name Connection String Retries Delay Actions
@@ -255,7 +167,7 @@
@dc.Name @dc.ConnectionString
@dc.MaxRetries @dc.RetryDelay.TotalSeconds s
- { _editingDbConn = dc; _dbConnName = dc.Name; _dbConnString = dc.ConnectionString; _dbConnMaxRetries = dc.MaxRetries; _dbConnRetryDelaySeconds = (int)dc.RetryDelay.TotalSeconds; _showDbConnForm = true; }">Edit
+ NavigationManager.NavigateTo($"/design/db-connections/{dc.Id}/edit")'>Edit
DeleteDbConn(dc)">Delete
@@ -264,19 +176,6 @@
};
- 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 @@
{
Notification Lists
- { _showNotifForm = true; _editingNotifList = null; _notifName = string.Empty; _notifFormError = null; }">Add List
+ NavigationManager.NavigateTo("/design/notification-lists/create")'>Add List
- @if (_showNotifForm)
- {
-
-
-
Name
-
- Save
- _showNotifForm = false">Cancel
-
- @if (_notifFormError != null) {
@_notifFormError
}
-
- }
-
- @if (_showRecipientForm)
- {
-
-
Add Recipient
-
-
Name
-
Email
-
- Add
- _showRecipientForm = false">Cancel
-
- @if (_recipientFormError != null) {
@_recipientFormError
}
-
- }
-
@foreach (var list in _notificationLists)
{
@@ -344,7 +215,6 @@
{
@r.Name <@r.EmailAddress>
- DeleteRecipient(r)">
}
}
@@ -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 =>
{
Inbound API Methods
- { _showApiMethodForm = true; _editingApiMethod = null; _apiMethodName = _apiMethodScript = string.Empty; _apiMethodTimeout = 30; _apiMethodParams = _apiMethodReturn = null; _apiMethodFormError = null; }">Add Method
+ NavigationManager.NavigateTo("/design/api-methods/create")'>Add Method
- @if (_showApiMethodForm)
- {
-
-
-
- Script
-
-
-
- Save
- _showApiMethodForm = false">Cancel
-
- @if (_apiMethodFormError != null) {
@_apiMethodFormError
}
-
- }
-
Name Timeout Script (preview) Actions
@@ -431,7 +248,7 @@
@m.TimeoutSeconds s
@m.Script[..Math.Min(60, m.Script.Length)]
- { _editingApiMethod = m; _apiMethodName = m.Name; _apiMethodScript = m.Script; _apiMethodTimeout = m.TimeoutSeconds; _apiMethodParams = m.ParameterDefinitions; _apiMethodReturn = m.ReturnDefinition; _showApiMethodForm = true; }">Edit
+ NavigationManager.NavigateTo($"/design/api-methods/{m.Id}/edit")'>Edit
DeleteApiMethod(m)">Delete
@@ -440,19 +257,6 @@
};
- 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;
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor
new file mode 100644
index 0000000..56d83c2
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor
@@ -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
+
+
+
← Back
+
+
@(Id.HasValue ? "Edit Notification List" : "Add Notification List")
+
+ @if (_loading)
+ {
+
+ }
+ else
+ {
+
+
+
+ Name
+
+
+
+ @if (_formError != null)
+ {
+
@_formError
+ }
+
+
+ Save
+ Cancel
+
+
+
+
+ @if (Id.HasValue)
+ {
+
Recipients
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+ @if (_recipientFormError != null)
+ {
+
@_recipientFormError
+ }
+
+ Add
+
+
+
+
+
+
+
+ Name
+ Email
+ Actions
+
+
+
+ @if (_recipients.Count == 0)
+ {
+ No recipients.
+ }
+ else
+ {
+ @foreach (var r in _recipients)
+ {
+
+ @r.Name
+ @r.EmailAddress
+
+ DeleteRecipient(r)">Delete
+
+
+ }
+ }
+
+
+ }
+ }
+
+
+@code {
+ [Parameter] public int? Id { get; set; }
+
+ private bool _loading = true;
+ private string _name = "";
+ private string? _formError;
+
+ private NotificationList? _existing;
+
+ // Recipients
+ private List
_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");
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor
new file mode 100644
index 0000000..f13b4a6
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor
@@ -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
+
+
+
+ ← Back
+
@(Id.HasValue ? $"Edit Shared Script: {_formName}" : "New Shared Script")
+
+
+ @if (_loading)
+ {
+
+ }
+ else
+ {
+
+
+
+ Name
+
+
+
+ Parameters (JSON)
+
+
+
+ Return Definition (JSON)
+
+
+
+ Code
+
+
+ @if (_formError != null)
+ {
+
@_formError
+ }
+ @if (_syntaxCheckResult != null)
+ {
+
@_syntaxCheckResult
+ }
+
+ Save
+ Check Syntax
+ Cancel
+
+
+
+ }
+
+
+@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 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}";
+ }
+ }
+
+ ///
+ /// Basic syntax check: balanced braces/brackets/parens.
+ /// Mirrors the internal SharedScriptService.ValidateSyntax logic.
+ ///
+ 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;
+ }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor
index b3e5472..7ad85e2 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor
@@ -7,11 +7,12 @@
@inject ITemplateEngineRepository TemplateEngineRepository
@inject SharedScriptService SharedScriptService
@inject AuthenticationStateProvider AuthStateProvider
+@inject NavigationManager NavigationManager
Shared Scripts
- New Script
+ NavigationManager.NavigateTo("/design/shared-scripts/create")'>New Script
@@ -27,50 +28,6 @@
}
else
{
- @if (_showForm)
- {
-
-
-
@(_editingScript == null ? "New Shared Script" : $"Edit: {_editingScript.Name}")
-
-
- Code
-
-
-
- Save
- Check Syntax
- Cancel
-
- @if (_formError != null)
- {
-
@_formError
- }
- @if (_syntaxCheckResult != null)
- {
-
@_syntaxCheckResult
- }
-
-
- }
-
@@ -101,7 +58,7 @@
@(script.ReturnDefinition ?? "—")
EditScript(script)">Edit
+ @onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{script.Id}/edit")'>Edit
DeleteScript(script)">Delete
@@ -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}";
- }
- }
-
- ///
- /// Basic syntax check: balanced braces/brackets/parens.
- /// Mirrors the internal SharedScriptService.ValidateSyntax logic.
- ///
- 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(
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateCreate.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateCreate.razor
new file mode 100644
index 0000000..dd5ccac
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateCreate.razor
@@ -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
+
+
+
+ ← Back
+
+
+
Create Template
+
+ @if (_loading)
+ {
+
+ }
+ else
+ {
+
+
+
+ Name
+
+
+
+ Parent Template
+
+ (None - root template)
+ @foreach (var t in _templates)
+ {
+ @t.Name
+ }
+
+
+
+ Description
+
+
+ @if (_formError != null)
+ {
+
@_formError
+ }
+
+ Create
+ Cancel
+
+
+
+ }
+
+
+@code {
+ private List _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 GetCurrentUserAsync()
+ {
+ var authState = await AuthStateProvider.GetAuthenticationStateAsync();
+ return authState.User.FindFirst("Username")?.Value ?? "unknown";
+ }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
index d8a5178..e87cafb 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
@@ -11,6 +11,7 @@
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
+@inject NavigationManager NavigationManager
@@ -29,46 +30,9 @@
@* Template list view *@
Templates
- New Template
+ NavigationManager.NavigateTo("/design/templates/create")'>New Template
- @if (_showCreateForm)
- {
-
-
-
Create Template
-
-
- Name
-
-
-
- Parent Template
-
- (None - root template)
- @foreach (var t in _templates)
- {
- @t.Name
- }
-
-
-
- Description
-
-
-
- Create
- _showCreateForm = false">Cancel
-
-
- @if (_createError != null)
- {
-
@_createError
- }
-
-
- }
-
@* Inheritance tree visualization *@
@@ -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(
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/LoginTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/LoginTests.cs
new file mode 100644
index 0000000..e1999f0
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/LoginTests.cs
@@ -0,0 +1,115 @@
+using System.Text.Json;
+using Microsoft.Playwright;
+
+namespace ScadaLink.CentralUI.PlaywrightTests;
+
+[Collection("Playwright")]
+public class LoginTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public LoginTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task UnauthenticatedUser_RedirectsToLogin()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ await page.GotoAsync(PlaywrightFixture.BaseUrl);
+
+ Assert.Contains("/login", page.Url);
+ await Expect(page.Locator("h4")).ToHaveTextAsync("ScadaLink");
+ await Expect(page.Locator("#username")).ToBeVisibleAsync();
+ await Expect(page.Locator("#password")).ToBeVisibleAsync();
+ await Expect(page.Locator("button[type='submit']")).ToHaveTextAsync("Sign In");
+ }
+
+ [Fact]
+ public async Task ValidCredentials_AuthenticatesSuccessfully()
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ // Should be on the dashboard, not the login page
+ Assert.DoesNotContain("/login", page.Url);
+ }
+
+ [Fact]
+ public async Task InvalidCredentials_ShowsError()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
+ await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+
+ // POST invalid credentials via fetch
+ var status = await page.EvaluateAsync
(@"
+ async () => {
+ const resp = await fetch('/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'username=baduser&password=badpass',
+ redirect: 'follow'
+ });
+ return resp.status;
+ }
+ ");
+
+ // The login endpoint redirects to /login?error=... on failure.
+ // Reload the page to see the error state.
+ await page.ReloadAsync();
+ Assert.Equal(200, status); // redirect followed to login page
+ }
+
+ [Fact]
+ public async Task TokenEndpoint_ReturnsJwt()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
+ await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+
+ var result = await page.EvaluateAsync(@"
+ async () => {
+ const resp = await fetch('/auth/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'username=multi-role&password=password'
+ });
+ const json = await resp.json();
+ return { status: resp.status, hasToken: !!json.access_token, username: json.username || '' };
+ }
+ ");
+
+ Assert.Equal(200, result.GetProperty("status").GetInt32());
+ Assert.True(result.GetProperty("hasToken").GetBoolean());
+ Assert.Equal("multi-role", result.GetProperty("username").GetString());
+ }
+
+ [Fact]
+ public async Task TokenEndpoint_InvalidCredentials_Returns401()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/login");
+ await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+
+ var status = await page.EvaluateAsync(@"
+ async () => {
+ const resp = await fetch('/auth/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'username=baduser&password=badpass'
+ });
+ return resp.status;
+ }
+ ");
+
+ Assert.Equal(401, status);
+ }
+
+ private static ILocatorAssertions Expect(ILocator locator) =>
+ Assertions.Expect(locator);
+}
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
new file mode 100644
index 0000000..3da7202
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
@@ -0,0 +1,76 @@
+using Microsoft.Playwright;
+
+namespace ScadaLink.CentralUI.PlaywrightTests;
+
+[Collection("Playwright")]
+public class NavigationTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public NavigationTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task Dashboard_IsVisibleAfterLogin()
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ // The nav sidebar should be visible with the brand
+ await Expect(page.Locator(".brand")).ToHaveTextAsync("ScadaLink");
+ // The nav should contain "Dashboard" link (exact match to avoid "Health Dashboard")
+ await Expect(page.GetByRole(AriaRole.Link, new() { Name = "Dashboard", Exact = true })).ToBeVisibleAsync();
+ }
+
+ [Theory]
+ [InlineData("Sites", "/admin/sites")]
+ [InlineData("Data Connections", "/admin/data-connections")]
+ [InlineData("API Keys", "/admin/api-keys")]
+ [InlineData("LDAP Mappings", "/admin/ldap-mappings")]
+ public async Task AdminNavLinks_NavigateCorrectly(string linkText, string expectedPath)
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await ClickNavAndWait(page, linkText, expectedPath);
+ }
+
+ [Theory]
+ [InlineData("Templates", "/design/templates")]
+ [InlineData("Shared Scripts", "/design/shared-scripts")]
+ [InlineData("External Systems", "/design/external-systems")]
+ public async Task DesignNavLinks_NavigateCorrectly(string linkText, string expectedPath)
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await ClickNavAndWait(page, linkText, expectedPath);
+ }
+
+ [Theory]
+ [InlineData("Instances", "/deployment/instances")]
+ [InlineData("Deployments", "/deployment/deployments")]
+ public async Task DeploymentNavLinks_NavigateCorrectly(string linkText, string expectedPath)
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await ClickNavAndWait(page, linkText, expectedPath);
+ }
+
+ [Theory]
+ [InlineData("Health Dashboard", "/monitoring/health")]
+ [InlineData("Event Logs", "/monitoring/event-logs")]
+ [InlineData("Parked Messages", "/monitoring/parked-messages")]
+ [InlineData("Audit Log", "/monitoring/audit-log")]
+ public async Task MonitoringNavLinks_NavigateCorrectly(string linkText, string expectedPath)
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await ClickNavAndWait(page, linkText, expectedPath);
+ }
+
+ private static async Task ClickNavAndWait(IPage page, string linkText, string expectedPath)
+ {
+ await page.Locator($"nav a:has-text('{linkText}')").ClickAsync();
+ await PlaywrightFixture.WaitForPathAsync(page, expectedPath);
+ Assert.Contains(expectedPath, page.Url);
+ }
+
+ private static ILocatorAssertions Expect(ILocator locator) =>
+ Assertions.Expect(locator);
+}
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs
new file mode 100644
index 0000000..0a8ba97
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs
@@ -0,0 +1,108 @@
+using Microsoft.Playwright;
+
+namespace ScadaLink.CentralUI.PlaywrightTests;
+
+///
+/// Shared fixture that manages the Playwright browser connection.
+/// Creates a single browser connection per test collection, reused across all tests.
+/// Requires the Playwright Docker container running at ws://localhost:3000.
+///
+public class PlaywrightFixture : IAsyncLifetime
+{
+ ///
+ /// Playwright Server WebSocket endpoint (Docker container on host port 3000).
+ ///
+ private const string PlaywrightWsEndpoint = "ws://localhost:3000";
+
+ ///
+ /// Central UI base URL as seen from inside the Docker network.
+ /// The browser runs in the Playwright container, so it uses the Docker hostname.
+ ///
+ public const string BaseUrl = "http://scadalink-traefik";
+
+ /// Test LDAP credentials (multi-role user with Admin + Design + Deployment).
+ public const string TestUsername = "multi-role";
+ public const string TestPassword = "password";
+
+ public IPlaywright Playwright { get; private set; } = null!;
+ public IBrowser Browser { get; private set; } = null!;
+
+ public async Task InitializeAsync()
+ {
+ Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
+ Browser = await Playwright.Chromium.ConnectAsync(PlaywrightWsEndpoint);
+ }
+
+ public async Task DisposeAsync()
+ {
+ await Browser.CloseAsync();
+ Playwright.Dispose();
+ }
+
+ ///
+ /// Create a new browser context and page. Each test gets an isolated session.
+ ///
+ public async Task NewPageAsync()
+ {
+ var context = await Browser.NewContextAsync();
+ return await context.NewPageAsync();
+ }
+
+ ///
+ /// Create a new page and log in with the test user.
+ /// Uses JavaScript fetch() to POST to /auth/login from within the browser,
+ /// which sets the auth cookie in the browser context. Then navigates to the dashboard.
+ ///
+ public async Task NewAuthenticatedPageAsync()
+ {
+ var page = await NewPageAsync();
+
+ // Navigate to the login page first to establish the origin
+ await page.GotoAsync($"{BaseUrl}/login");
+ await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
+
+ // POST to /auth/login via fetch() inside the browser.
+ // This sets the auth cookie in the browser context automatically.
+ // Use redirect: 'follow' so the browser follows the 302 and the cookie is stored.
+ var finalUrl = await page.EvaluateAsync(@"
+ async () => {
+ const resp = await fetch('/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'username=' + encodeURIComponent('" + TestUsername + @"')
+ + '&password=' + encodeURIComponent('" + TestPassword + @"'),
+ redirect: 'follow'
+ });
+ return resp.url;
+ }
+ ");
+
+ // The fetch followed the redirect. If it ended on /login, auth failed.
+ if (finalUrl.Contains("/login"))
+ {
+ throw new InvalidOperationException($"Login failed — redirected back to login: {finalUrl}");
+ }
+
+ // Navigate to the dashboard — cookie authenticates us
+ await page.GotoAsync(BaseUrl);
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ return page;
+ }
+
+ ///
+ /// Wait for Blazor enhanced navigation to update the URL path.
+ /// Blazor Server uses SignalR for client-side navigation (no full page reload),
+ /// so standard WaitForURLAsync times out. This polls window.location instead.
+ ///
+ public static async Task WaitForPathAsync(IPage page, string path, string? excludePath = null, int timeoutMs = 10000)
+ {
+ var js = excludePath != null
+ ? $"() => window.location.pathname.includes('{path}') && !window.location.pathname.includes('{excludePath}')"
+ : $"() => window.location.pathname.includes('{path}')";
+ await page.WaitForFunctionAsync(js, null, new() { Timeout = timeoutMs });
+ }
+}
+
+[CollectionDefinition("Playwright")]
+public class PlaywrightCollection : ICollectionFixture;
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj b/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj
new file mode 100644
index 0000000..de467ec
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCrudTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCrudTests.cs
new file mode 100644
index 0000000..56279d1
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCrudTests.cs
@@ -0,0 +1,94 @@
+using Microsoft.Playwright;
+
+namespace ScadaLink.CentralUI.PlaywrightTests;
+
+[Collection("Playwright")]
+public class SiteCrudTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public SiteCrudTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task SitesPage_ShowsTable()
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
+ await Expect(page.Locator("table")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task AddSiteButton_NavigatesToCreatePage()
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await page.ClickAsync("button:has-text('Add Site')");
+
+ await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/create");
+ var inputCount = await page.Locator("input").CountAsync();
+ Assert.True(inputCount >= 2, $"Expected at least 2 inputs, found {inputCount}");
+ }
+
+ [Fact]
+ public async Task CreatePage_BackButton_ReturnsToList()
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await page.ClickAsync("button:has-text('Back')");
+
+ await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
+ await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task CreatePage_CancelButton_ReturnsToList()
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await page.ClickAsync("button:has-text('Cancel')");
+
+ await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
+ }
+
+ [Fact]
+ public async Task CreatePage_HasNodeSubsections()
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Expect(page.Locator("h6:has-text('Node A')")).ToBeVisibleAsync();
+ await Expect(page.Locator("h6:has-text('Node B')")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task CreatePage_SaveWithoutName_ShowsError()
+ {
+ var page = await _fixture.NewAuthenticatedPageAsync();
+
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await page.ClickAsync("button:has-text('Save')");
+
+ // Should stay on create page with validation error
+ Assert.Contains("/admin/sites/create", page.Url);
+ await Expect(page.Locator(".text-danger")).ToBeVisibleAsync();
+ }
+
+ private static ILocatorAssertions Expect(ILocator locator) =>
+ Assertions.Expect(locator);
+}
diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/xunit.runner.json b/tests/ScadaLink.CentralUI.PlaywrightTests/xunit.runner.json
new file mode 100644
index 0000000..12b9b26
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.PlaywrightTests/xunit.runner.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
+ "maxParallelThreads": 1
+}