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

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

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

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

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

View File

@@ -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];
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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}");
}
}
}