From 3b2320bd35c7336564f5b2160a0647446ea9bc38 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 16 Mar 2026 21:47:37 -0400 Subject: [PATCH] =?UTF-8?q?Phases=204-6:=20Complete=20Central=20UI=20?= =?UTF-8?q?=E2=80=94=20Admin,=20Design,=20Deployment,=20and=20Operations?= =?UTF-8?q?=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Components/Layout/NavMenu.razor | 23 +- .../Components/Pages/Admin/ApiKeys.razor | 264 +++++ .../Components/Pages/Admin/Areas.razor | 290 +++++- .../Pages/Admin/DataConnections.razor | 364 +++++++ .../Components/Pages/Admin/Sites.razor | 247 ++++- .../Pages/Deployment/DebugView.razor | 293 +++++- .../Pages/Deployment/Deployments.razor | 231 ++++- .../Pages/Deployment/Instances.razor | 366 ++++++- .../Pages/Design/ExternalSystems.razor | 435 +++++++- .../Pages/Design/SharedScripts.razor | 284 ++++- .../Components/Pages/Design/Templates.razor | 978 +++++++++++++++++- .../Pages/Monitoring/AuditLog.razor | 188 ++++ .../Pages/Monitoring/EventLogs.razor | 183 ++++ .../Components/Pages/Monitoring/Health.razor | 194 +++- .../Pages/Monitoring/ParkedMessages.razor | 153 +++ .../Components/Shared/ConfirmDialog.razor | 55 + .../Components/Shared/DataTable.razor | 128 +++ .../Components/Shared/LoadingSpinner.razor | 17 + .../Components/Shared/TimestampDisplay.razor | 8 + .../Components/Shared/ToastNotification.razor | 88 ++ .../ScadaLink.CentralUI.csproj | 4 + tests/ScadaLink.CentralUI.Tests/UnitTest1.cs | 60 +- 22 files changed, 4821 insertions(+), 32 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Monitoring/AuditLog.razor create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Monitoring/EventLogs.razor create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Monitoring/ParkedMessages.razor create mode 100644 src/ScadaLink.CentralUI/Components/Shared/ConfirmDialog.razor create mode 100644 src/ScadaLink.CentralUI/Components/Shared/DataTable.razor create mode 100644 src/ScadaLink.CentralUI/Components/Shared/LoadingSpinner.razor create mode 100644 src/ScadaLink.CentralUI/Components/Shared/TimestampDisplay.razor create mode 100644 src/ScadaLink.CentralUI/Components/Shared/ToastNotification.razor diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 165f443..26f80b8 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -20,9 +20,15 @@ + + @@ -58,11 +64,26 @@ - @* Health — visible to all authenticated users *@ + @* Monitoring — visible to all authenticated users *@ + + + + @* Audit Log — Admin only *@ + + + + + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor new file mode 100644 index 0000000..9bbc5b3 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor @@ -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 + +
+
+

API Key Management

+ +
+ + + + + @if (_loading) + { + + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { + @if (_showForm) + { +
+
+
@(_editingKey == null ? "Add New API Key" : "Edit API Key")
+
+
+ + +
+
+ + +
+
+ @if (_formError != null) + { +
@_formError
+ } +
+
+ } + + @if (_newlyCreatedKeyValue != null) + { +
+ New API Key Created +
+ @_newlyCreatedKeyValue + +
+ Save this key now. It will not be shown again in full. + +
+ } + + + + + + + + + + + + + @if (_keys.Count == 0) + { + + + + } + @foreach (var key in _keys) + { + + + + + + + + } + +
IDNameKey ValueStatusActions
No API keys configured.
@key.Id@key.Name@MaskKeyValue(key.KeyValue) + @if (key.IsEnabled) + { + Enabled + } + else + { + Disabled + } + + + @if (key.IsEnabled) + { + + } + else + { + + } + +
+ } +
+ +@code { + private List _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]; + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor index 47540d9..e0f9263 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor @@ -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 -
-

Areas

-

Area management will be available in a future phase.

+
+
+

Area Management

+
+ + + + + @if (_loading) + { + + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { +
+
+
+
+
Sites
+
+
+ @if (_sites.Count == 0) + { +
No sites configured.
+ } + @foreach (var site in _sites) + { + + } +
+
+
+ +
+ @if (_selectedSiteId == 0) + { +
Select a site to manage its areas.
+ } + else + { +
+
Areas for @(_sites.FirstOrDefault(s => s.Id == _selectedSiteId)?.Name)
+ +
+ + @if (_showForm) + { +
+
+
@(_editingArea == null ? "Add New Area" : "Edit Area")
+
+
+ + +
+ @if (_editingArea == null) + { +
+ + +
+ } +
+ + +
+
+ @if (_formError != null) + { +
@_formError
+ } +
+
+ } + + @if (_areas.Count == 0) + { +
No areas configured for this site.
+ } + else + { +
+
+ @foreach (var node in BuildFlatTree()) + { +
+ + @(node.HasChildren ? "[+]" : " -") + + @node.Area.Name + + +
+ } +
+
+ } + } +
+
+ }
+ +@code { + private List _sites = new(); + private List _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 BuildFlatTree() + { + var result = new List(); + AddChildren(null, 0, result); + return result; + } + + private void AddChildren(int? parentId, int depth, List 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(); + 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}"); + } + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor new file mode 100644 index 0000000..687f566 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor @@ -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 + +
+
+

Data Connections

+ +
+ + + + + @if (_loading) + { + + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { + @if (_showForm) + { +
+
+
@(_editingConnection == null ? "Add New Connection" : "Edit Connection")
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ @if (_formError != null) + { +
@_formError
+ } +
+
+ } + + @* Assignment form *@ + @if (_showAssignForm) + { +
+
+
Assign Connection to Site
+
+
+ + +
+
+ + +
+
+ + +
+
+ @if (_assignError != null) + { +
@_assignError
+ } +
+
+ } + +
+ +
+ + + + + + + + + + + + + + @if (_connections.Count == 0) + { + + + + } + @foreach (var conn in _connections) + { + + + + + + + + + } + +
IDNameProtocolConfigurationAssigned SitesActions
No data connections configured.
@conn.Id@conn.Name@conn.Protocol@(conn.Configuration ?? "—") + @{ + 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}"; + + @siteName + + + } + } + else + { + None + } + + + +
+ } +
+ +@code { + private List _connections = new(); + private List _sites = new(); + private Dictionary> _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(); + _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(); + + 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}"); + } + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor index 69af4d3..8b71ac2 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor @@ -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 -
-

Sites

-

Site management will be available in a future phase.

+
+
+

Site Management

+ +
+ + + + + @if (_loading) + { + + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { + @if (_showForm) + { +
+
+
@(_editingSite == null ? "Add New Site" : "Edit Site")
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ @if (_formError != null) + { +
@_formError
+ } +
+
+ } + + + + + + + + + + + + + + @if (_sites.Count == 0) + { + + + + } + @foreach (var site in _sites) + { + + + + + + + + + } + +
IDNameIdentifierDescriptionData ConnectionsActions
No sites configured.
@site.Id@site.Name@site.SiteIdentifier@(site.Description ?? "—") + @{ + var conns = _siteConnections.GetValueOrDefault(site.Id); + } + @if (conns != null && conns.Count > 0) + { + @foreach (var conn in conns) + { + @conn.Name (@conn.Protocol) + } + } + else + { + None + } + + + +
+ }
+ +@code { + private List _sites = new(); + private Dictionary> _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}"); + } + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor index d13984a..47c5cf8 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -1,11 +1,294 @@ @page "/deployment/debug-view" @using ScadaLink.Security +@using ScadaLink.Commons.Entities.Instances +@using ScadaLink.Commons.Entities.Sites +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Commons.Messages.DebugView +@using ScadaLink.Commons.Messages.Streaming +@using ScadaLink.Commons.Types.Enums +@using ScadaLink.Communication @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] +@inject ITemplateEngineRepository TemplateEngineRepository +@inject ISiteRepository SiteRepository +@inject CommunicationService CommunicationService +@implements IDisposable -
-

Debug View

-

Real-time debug view will be available in a future phase.

-