@* Reference pattern for list pages: card grid (col-lg-6) + flex header + search filter + kebab dropdown + Bootstrap collapse for noisy detail + @key on iterated cards + "No X match the filter." inline + empty-state CTA. Mirror this when building new list pages. *@ @page "/admin/sites" @using ScadaLink.Security @using ScadaLink.Commons.Entities.Sites @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Communication @using ScadaLink.DeploymentManager @attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @inject ISiteRepository SiteRepository @inject ArtifactDeploymentService ArtifactDeploymentService @inject CommunicationService CommunicationService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager NavigationManager @inject IJSRuntime JS @inject IDialogService Dialog @inject Microsoft.Extensions.Logging.ILogger Logger

Site Management

@if (_loading) { } else if (_errorMessage != null) {
@_errorMessage
} else if (_sites.Count == 0) {

No sites configured.

} else {
@if (!FilteredSites.Any()) {

No sites match the filter.

}
@foreach (var site in FilteredSites) { var conns = _siteConnections.GetValueOrDefault(site.Id); var collapseId = $"cluster-{site.Id}";
@site.Name
@site.SiteIdentifier

@(string.IsNullOrWhiteSpace(site.Description) ? "—" : site.Description)

Data connections
@if (conns is { Count: > 0 }) {
    @foreach (var c in conns) {
  • @c.Protocol @c.Name
  • }
} else {

No connections.

}
@ClusterRow("Node A", site.NodeAAddress) @ClusterRow("Node B", site.NodeBAddress) @ClusterRow("gRPC A", site.GrpcNodeAAddress) @ClusterRow("gRPC B", site.GrpcNodeBAddress)
}
}
@code { private async Task GetCurrentUserAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); return authState.User.FindFirst("Username")?.Value ?? "unknown"; } private List _sites = new(); private Dictionary> _siteConnections = new(); private bool _loading = true; private string? _errorMessage; private bool _deploying; private string _search = ""; private ToastNotification _toast = default!; private IEnumerable FilteredSites => string.IsNullOrWhiteSpace(_search) ? _sites : _sites.Where(s => (s.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) || (s.SiteIdentifier?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false)); protected override async Task OnInitializedAsync() { await LoadDataAsync(); } private async Task LoadDataAsync() { _loading = true; _errorMessage = null; try { _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); // CentralUI-012: fetch all data connections in one query and group // them by site, instead of issuing one query per site (N+1). _siteConnections = (await SiteRepository.GetAllDataConnectionsAsync()) .GroupBy(c => c.SiteId) .ToDictionary(g => g.Key, g => g.ToList()); } catch (Exception ex) { _errorMessage = $"Failed to load sites: {ex.Message}"; } _loading = false; } private async Task DeleteSite(Site site) { var confirmed = await Dialog.ConfirmAsync( "Delete Site", $"Delete site '{site.Name}' ({site.SiteIdentifier})? This cannot be undone.", danger: true); if (!confirmed) return; try { await SiteRepository.DeleteSiteAsync(site.Id); await SiteRepository.SaveChangesAsync(); CommunicationService.RefreshSiteAddresses(); _toast.ShowSuccess($"Site '{site.Name}' deleted."); await LoadDataAsync(); } catch (Exception ex) { _toast.ShowError($"Delete failed: {ex.Message}"); } } private async Task DeployArtifacts(Site site) { _deploying = true; try { var user = await GetCurrentUserAsync(); var result = await ArtifactDeploymentService.RetryForSiteAsync( site.Id, site.SiteIdentifier, user); if (result.IsSuccess) _toast.ShowSuccess($"Artifacts deployed to '{site.Name}'."); else _toast.ShowError($"Deploy to '{site.Name}' failed: {result.Error}"); } catch (Exception ex) { _toast.ShowError($"Deploy to '{site.Name}' failed: {ex.Message}"); } finally { _deploying = false; } } private async Task DeployArtifactsToAllSites() { _deploying = true; try { var user = await GetCurrentUserAsync(); var result = await ArtifactDeploymentService.DeployToAllSitesAsync(user); if (result.IsSuccess) { var summary = result.Value!; _toast.ShowSuccess( $"Artifacts deployed: {summary.SuccessCount} succeeded, {summary.FailureCount} failed."); } else { _toast.ShowError($"Artifact deployment failed: {result.Error}"); } } catch (Exception ex) { _toast.ShowError($"Artifact deployment failed: {ex.Message}"); } finally { _deploying = false; } } private RenderFragment ClusterRow(string label, string? address) => __builder => {
@label
@(string.IsNullOrWhiteSpace(address) ? "—" : address)
@if (!string.IsNullOrWhiteSpace(address)) { }
}; private async Task CopyAsync(string text) { try { await JS.InvokeVoidAsync("navigator.clipboard.writeText", text); _toast.ShowSuccess("Copied to clipboard."); } catch (Microsoft.JSInterop.JSDisconnectedException) { // Circuit gone — the user has navigated away; nothing to surface. } catch (Microsoft.JSInterop.JSException ex) { // CentralUI-018: a real clipboard failure (e.g. permission denied) // is logged, not silently swallowed. Logger.LogWarning(ex, "Clipboard copy failed."); _toast.ShowError("Copy failed."); } } }