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:
144
src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
Normal file
144
src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
Normal 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">← Back to API Keys</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">← 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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">← 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");
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
← 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
163
src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor
Normal file
163
src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor
Normal 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">
|
||||
← 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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">← 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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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">← 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");
|
||||
}
|
||||
@@ -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">← 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");
|
||||
}
|
||||
@@ -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">← 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");
|
||||
}
|
||||
@@ -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 <@r.EmailAddress>
|
||||
<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;
|
||||
|
||||
@@ -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">← 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");
|
||||
}
|
||||
@@ -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">← 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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">← 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";
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user