Phases 4-6: Complete Central UI — Admin, Design, Deployment, and Operations pages
Phase 4 — Operator/Admin UI: - Sites, DataConnections, Areas (hierarchical), API Keys (auto-generated) CRUD - Health Dashboard (live refresh, per-site metrics from CentralHealthAggregator) - Instance list with filtering/staleness/lifecycle actions - Deployment status tracking with auto-refresh Phase 5 — Authoring UI: - Template authoring with inheritance tree, tabs (attrs/alarms/scripts/compositions) - Lock indicators, on-demand validation, collision detection - Shared scripts with syntax check - External systems, DB connections, notification lists, Inbound API methods Phase 6 — Deployment Operations UI: - Staleness indicators, validation gating - Debug view (instance selection, attribute/alarm live tables) - Site event log viewer (filters, keyword search, keyset pagination) - Parked message management, Audit log viewer with JSON state Shared components: DataTable, ConfirmDialog, ToastNotification, LoadingSpinner, TimestampDisplay 623 tests pass, zero warnings. All Bootstrap 5, clean corporate design.
This commit is contained in:
264
src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor
Normal file
264
src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor
Normal file
@@ -0,0 +1,264 @@
|
||||
@page "/admin/api-keys"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.InboundApi
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(_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>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Key Value</th>
|
||||
<th>Status</th>
|
||||
<th style="width: 240px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_keys.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-center">No API keys configured.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var key in _keys)
|
||||
{
|
||||
<tr>
|
||||
<td>@key.Id</td>
|
||||
<td>@key.Name</td>
|
||||
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
|
||||
<td>
|
||||
@if (key.IsEnabled)
|
||||
{
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EditKey(key)">Edit</button>
|
||||
@if (key.IsEnabled)
|
||||
{
|
||||
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleKey(key)">Disable</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleKey(key)">Enable</button>
|
||||
}
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteKey(key)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<ApiKey> _keys = new();
|
||||
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!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load API keys: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static string MaskKeyValue(string keyValue)
|
||||
{
|
||||
if (keyValue.Length <= 8) return new string('*', keyValue.Length);
|
||||
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
|
||||
{
|
||||
key.IsEnabled = !key.IsEnabled;
|
||||
await InboundApiRepository.UpdateApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Toggle failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteKey(ApiKey key)
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
$"Delete API key '{key.Name}'? This cannot be undone.", "Delete API Key");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await InboundApiRepository.DeleteApiKeyAsync(key.Id);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,292 @@
|
||||
@page "/admin/areas"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Areas</h4>
|
||||
<p class="text-muted">Area management will be available in a future phase.</p>
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Area Management</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Sites</h6>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
@if (_sites.Count == 0)
|
||||
{
|
||||
<div class="list-group-item text-muted small">No sites configured.</div>
|
||||
}
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<button type="button"
|
||||
class="list-group-item list-group-item-action @(site.Id == _selectedSiteId ? "active" : "")"
|
||||
@onclick="() => SelectSite(site.Id)">
|
||||
@site.Name
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
@if (_selectedSiteId == 0)
|
||||
{
|
||||
<div class="text-muted">Select a site to manage its areas.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">Areas for @(_sites.FirstOrDefault(s => s.Id == _selectedSiteId)?.Name)</h5>
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Area</button>
|
||||
</div>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(_editingArea == null ? "Add New Area" : "Edit Area")</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>
|
||||
@if (_editingArea == null)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Parent Area</label>
|
||||
<select class="form-select form-select-sm" @bind="_formParentAreaId">
|
||||
<option value="0">(Root level)</option>
|
||||
@foreach (var area in _areas)
|
||||
{
|
||||
<option value="@area.Id">@GetAreaPath(area)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveArea">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 (_areas.Count == 0)
|
||||
{
|
||||
<div class="text-muted">No areas configured for this site.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
@foreach (var node in BuildFlatTree())
|
||||
{
|
||||
<div class="d-flex align-items-center py-1 border-bottom"
|
||||
style="padding-left: @(node.Depth * 24 + 8)px;">
|
||||
<span class="me-2 text-muted small">
|
||||
@(node.HasChildren ? "[+]" : " -")
|
||||
</span>
|
||||
<span class="flex-grow-1">@node.Area.Name</span>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EditArea(node.Area)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteArea(node.Area)">Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<Site> _sites = new();
|
||||
private List<Area> _areas = new();
|
||||
private int _selectedSiteId;
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private bool _showForm;
|
||||
private Area? _editingArea;
|
||||
private string _formName = string.Empty;
|
||||
private int _formParentAreaId;
|
||||
private string? _formError;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load sites: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task SelectSite(int siteId)
|
||||
{
|
||||
_selectedSiteId = siteId;
|
||||
_showForm = false;
|
||||
await LoadAreasAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAreasAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load areas: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private record AreaTreeNode(Area Area, int Depth, bool HasChildren);
|
||||
|
||||
private List<AreaTreeNode> BuildFlatTree()
|
||||
{
|
||||
var result = new List<AreaTreeNode>();
|
||||
AddChildren(null, 0, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void AddChildren(int? parentId, int depth, List<AreaTreeNode> result)
|
||||
{
|
||||
var children = _areas.Where(a => a.ParentAreaId == parentId).OrderBy(a => a.Name);
|
||||
foreach (var child in children)
|
||||
{
|
||||
var hasChildren = _areas.Any(a => a.ParentAreaId == child.Id);
|
||||
result.Add(new AreaTreeNode(child, depth, hasChildren));
|
||||
AddChildren(child.Id, depth + 1, result);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAreaPath(Area area)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var current = area;
|
||||
while (current != null)
|
||||
{
|
||||
parts.Insert(0, current.Name);
|
||||
current = current.ParentAreaId.HasValue
|
||||
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
|
||||
: null;
|
||||
}
|
||||
return string.Join(" / ", parts);
|
||||
}
|
||||
|
||||
private void ShowAddForm()
|
||||
{
|
||||
_editingArea = null;
|
||||
_formName = string.Empty;
|
||||
_formParentAreaId = 0;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void EditArea(Area area)
|
||||
{
|
||||
_editingArea = area;
|
||||
_formName = area.Name;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void CancelForm()
|
||||
{
|
||||
_showForm = false;
|
||||
_editingArea = null;
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private async Task SaveArea()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingArea != null)
|
||||
{
|
||||
_editingArea.Name = _formName.Trim();
|
||||
await TemplateEngineRepository.UpdateAreaAsync(_editingArea);
|
||||
}
|
||||
else
|
||||
{
|
||||
var area = new Area(_formName.Trim())
|
||||
{
|
||||
SiteId = _selectedSiteId,
|
||||
ParentAreaId = _formParentAreaId == 0 ? null : _formParentAreaId
|
||||
};
|
||||
await TemplateEngineRepository.AddAreaAsync(area);
|
||||
}
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
_showForm = false;
|
||||
_editingArea = null;
|
||||
_toast.ShowSuccess("Area saved.");
|
||||
await LoadAreasAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteArea(Area area)
|
||||
{
|
||||
var hasChildren = _areas.Any(a => a.ParentAreaId == area.Id);
|
||||
var message = hasChildren
|
||||
? $"Area '{area.Name}' has child areas. Delete child areas first."
|
||||
: $"Delete area '{area.Name}'?";
|
||||
|
||||
var confirmed = await _confirmDialog.ShowAsync(message, "Delete Area");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"Area '{area.Name}' deleted.");
|
||||
await LoadAreasAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
@page "/admin/data-connections"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(_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)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Assign Connection to Site</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Connection</label>
|
||||
<select class="form-select form-select-sm" @bind="_assignConnectionId">
|
||||
<option value="0">Select connection...</option>
|
||||
@foreach (var conn in _connections)
|
||||
{
|
||||
<option value="@conn.Id">@conn.Name (@conn.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_assignSiteId">
|
||||
<option value="0">Select site...</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveAssignment">Assign</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAssignForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_assignError != null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_assignError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-outline-info btn-sm" @onclick="ShowAssignForm">Assign to Site</button>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Protocol</th>
|
||||
<th>Configuration</th>
|
||||
<th>Assigned Sites</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_connections.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-center">No data connections configured.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var conn in _connections)
|
||||
{
|
||||
<tr>
|
||||
<td>@conn.Id</td>
|
||||
<td>@conn.Name</td>
|
||||
<td><span class="badge bg-secondary">@conn.Protocol</span></td>
|
||||
<td class="text-muted small text-truncate" style="max-width: 300px;">@(conn.Configuration ?? "—")</td>
|
||||
<td>
|
||||
@{
|
||||
var assignedSites = _connectionSites.GetValueOrDefault(conn.Id);
|
||||
}
|
||||
@if (assignedSites != null && assignedSites.Count > 0)
|
||||
{
|
||||
@foreach (var assignment in assignedSites)
|
||||
{
|
||||
var siteName = _sites.FirstOrDefault(s => s.Id == assignment.SiteId)?.Name ?? $"Site {assignment.SiteId}";
|
||||
<span class="badge bg-info text-dark me-1">
|
||||
@siteName
|
||||
<button type="button" class="btn-close btn-close-white ms-1"
|
||||
style="font-size: 0.5rem;"
|
||||
@onclick="() => RemoveAssignment(assignment)"></button>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">None</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EditConnection(conn)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteConnection(conn)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<DataConnection> _connections = new();
|
||||
private List<Site> _sites = new();
|
||||
private Dictionary<int, List<SiteDataConnectionAssignment>> _connectionSites = new();
|
||||
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;
|
||||
private string? _assignError;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
|
||||
// Load all connections by iterating all sites and collecting unique connections
|
||||
var allConnections = new Dictionary<int, DataConnection>();
|
||||
_connectionSites.Clear();
|
||||
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
|
||||
foreach (var conn in siteConns)
|
||||
{
|
||||
allConnections[conn.Id] = conn;
|
||||
if (!_connectionSites.ContainsKey(conn.Id))
|
||||
_connectionSites[conn.Id] = new List<SiteDataConnectionAssignment>();
|
||||
|
||||
var assignment = await SiteRepository.GetSiteDataConnectionAssignmentAsync(site.Id, conn.Id);
|
||||
if (assignment != null)
|
||||
_connectionSites[conn.Id].Add(assignment);
|
||||
}
|
||||
}
|
||||
|
||||
_connections = allConnections.Values.OrderBy(c => c.Name).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load data: {ex.Message}";
|
||||
}
|
||||
_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(
|
||||
$"Delete data connection '{conn.Name}'?", "Delete Connection");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await SiteRepository.DeleteDataConnectionAsync(conn.Id);
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"Connection '{conn.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAssignForm()
|
||||
{
|
||||
_assignConnectionId = 0;
|
||||
_assignSiteId = 0;
|
||||
_assignError = null;
|
||||
_showAssignForm = true;
|
||||
}
|
||||
|
||||
private void CancelAssignForm()
|
||||
{
|
||||
_showAssignForm = false;
|
||||
_assignError = null;
|
||||
}
|
||||
|
||||
private async Task SaveAssignment()
|
||||
{
|
||||
_assignError = null;
|
||||
if (_assignConnectionId == 0) { _assignError = "Select a connection."; return; }
|
||||
if (_assignSiteId == 0) { _assignError = "Select a site."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
var assignment = new SiteDataConnectionAssignment
|
||||
{
|
||||
SiteId = _assignSiteId,
|
||||
DataConnectionId = _assignConnectionId
|
||||
};
|
||||
await SiteRepository.AddSiteDataConnectionAssignmentAsync(assignment);
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_showAssignForm = false;
|
||||
_toast.ShowSuccess("Connection assigned to site.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_assignError = $"Assignment failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveAssignment(SiteDataConnectionAssignment assignment)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SiteRepository.DeleteSiteDataConnectionAssignmentAsync(assignment.Id);
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess("Assignment removed.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Remove failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,249 @@
|
||||
@page "/admin/sites"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Sites</h4>
|
||||
<p class="text-muted">Site management will be available in a future phase.</p>
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Site Management</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Site</button>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(_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>
|
||||
@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>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Identifier</th>
|
||||
<th>Description</th>
|
||||
<th>Data Connections</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_sites.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-center">No sites configured.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<tr>
|
||||
<td>@site.Id</td>
|
||||
<td>@site.Name</td>
|
||||
<td><code>@site.SiteIdentifier</code></td>
|
||||
<td class="text-muted small">@(site.Description ?? "—")</td>
|
||||
<td>
|
||||
@{
|
||||
var conns = _siteConnections.GetValueOrDefault(site.Id);
|
||||
}
|
||||
@if (conns != null && conns.Count > 0)
|
||||
{
|
||||
@foreach (var conn in conns)
|
||||
{
|
||||
<span class="badge bg-info text-dark me-1">@conn.Name (@conn.Protocol)</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">None</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EditSite(site)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteSite(site)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<Site> _sites = new();
|
||||
private Dictionary<int, List<DataConnection>> _siteConnections = new();
|
||||
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? _formError;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
_siteConnections.Clear();
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var connections = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
|
||||
if (connections.Count > 0)
|
||||
{
|
||||
_siteConnections[site.Id] = connections.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load sites: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void ShowAddForm()
|
||||
{
|
||||
_editingSite = null;
|
||||
_formName = string.Empty;
|
||||
_formIdentifier = string.Empty;
|
||||
_formDescription = null;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void EditSite(Site site)
|
||||
{
|
||||
_editingSite = site;
|
||||
_formName = site.Name;
|
||||
_formIdentifier = site.SiteIdentifier;
|
||||
_formDescription = site.Description;
|
||||
_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();
|
||||
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()
|
||||
};
|
||||
await SiteRepository.AddSiteAsync(site);
|
||||
}
|
||||
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_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(
|
||||
$"Delete site '{site.Name}' ({site.SiteIdentifier})? This cannot be undone.",
|
||||
"Delete Site");
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await SiteRepository.DeleteSiteAsync(site.Id);
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"Site '{site.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user