9e243493fb
Applies the family-wide admin-UI cleanup playbook to the Central UI so the Blazor surfaces stop diverging from the shared kit: buttons are grouped rather than individually sized, long cell values are contained instead of widening tables, and hard-coded colours give way to theme tokens. The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell, which the kit emits as an inline style on the shell root. Being a descendant of <html>, it beat the [data-bs-theme="dark"] override for the entire app, so the dark accent had never rendered. Declaring --accent in site.css :root instead lets both schemes resolve; light is unchanged because the value already matched the kit's light default. Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so that block is deleted here rather than duplicated. Verified byte-identical before removal; the repo now declares no --bs-btn-* anywhere. NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages, SiteCallsReport) were additionally refactored from holding the selected record to holding its id and re-resolving from the current page each render, with the resolve doubling as the visibility gate. A background refresh that drops the row now closes the modal instead of showing a stale snapshot. This is a behaviour change and is called out rather than buried: a full-suite run turned up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack (GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler was disposed between render and click — a window the previous field-held record made structurally impossible. Treat the modal lifecycle here as unreviewed. Build 0/0; suite green apart from that one intermittent failure.
328 lines
13 KiB
Plaintext
328 lines
13 KiB
Plaintext
@* 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 ZB.MOM.WW.ScadaBridge.Security
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
|
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
|
@using ZB.MOM.WW.ScadaBridge.Communication
|
|
@using ZB.MOM.WW.ScadaBridge.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<Sites> Logger
|
|
|
|
<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>
|
|
<div class="d-flex gap-2">
|
|
<div class="dropdown">
|
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
|
data-bs-toggle="dropdown" disabled="@_deploying">
|
|
@if (_deploying)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
|
}
|
|
Bulk actions
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end">
|
|
<li>
|
|
<button class="dropdown-item" @onclick="DeployArtifactsToAllSites">
|
|
Deploy Artifacts to All Sites
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm"
|
|
@onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>
|
|
+ Add Site
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ToastNotification @ref="_toast" />
|
|
|
|
@if (_loading)
|
|
{
|
|
<LoadingSpinner IsLoading="true" />
|
|
}
|
|
else if (_errorMessage != null)
|
|
{
|
|
<div class="alert alert-danger">@_errorMessage</div>
|
|
}
|
|
else if (_sites.Count == 0)
|
|
{
|
|
<div class="text-center py-5 text-muted">
|
|
<p class="mb-3">No sites configured.</p>
|
|
<button class="btn btn-primary btn-sm"
|
|
@onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>
|
|
Add your first site
|
|
</button>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="mb-3" style="max-width: 320px;">
|
|
<input class="form-control form-control-sm"
|
|
placeholder="Filter by name or identifier…"
|
|
@bind="_search" @bind:event="oninput" />
|
|
</div>
|
|
|
|
@if (!FilteredSites.Any())
|
|
{
|
|
<p class="text-muted small">No sites match the filter.</p>
|
|
}
|
|
|
|
<div class="row g-3">
|
|
@foreach (var site in FilteredSites)
|
|
{
|
|
var conns = _siteConnections.GetValueOrDefault(site.Id);
|
|
var collapseId = $"cluster-{site.Id}";
|
|
<div class="col-lg-6 col-12" @key="site.Id">
|
|
<div class="card h-100">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
<div>
|
|
<h5 class="card-title mb-1">@site.Name</h5>
|
|
<code class="small">@site.SiteIdentifier</code>
|
|
</div>
|
|
<div class="d-flex gap-1">
|
|
<button class="btn btn-outline-primary btn-sm"
|
|
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>
|
|
Edit
|
|
</button>
|
|
<div class="dropdown">
|
|
<button class="btn btn-outline-secondary btn-sm"
|
|
data-bs-toggle="dropdown" aria-label="More actions">⋮</button>
|
|
<ul class="dropdown-menu dropdown-menu-end">
|
|
<li>
|
|
<button class="dropdown-item"
|
|
@onclick="() => DeployArtifacts(site)"
|
|
disabled="@_deploying">
|
|
Deploy Artifacts
|
|
</button>
|
|
</li>
|
|
<li><hr class="dropdown-divider" /></li>
|
|
<li>
|
|
<button class="dropdown-item text-danger"
|
|
@onclick="() => DeleteSite(site)">
|
|
Delete
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="text-muted small mb-3">
|
|
@(string.IsNullOrWhiteSpace(site.Description) ? "—" : site.Description)
|
|
</p>
|
|
|
|
<div class="small text-muted mb-1">Data connections</div>
|
|
@if (conns is { Count: > 0 })
|
|
{
|
|
<ul class="list-unstyled mb-3">
|
|
@foreach (var c in conns)
|
|
{
|
|
<li class="mb-1">
|
|
<span class="badge bg-info text-dark me-1">@c.Protocol</span>
|
|
@c.Name
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted small fst-italic mb-3">No connections.</p>
|
|
}
|
|
|
|
<button class="btn btn-link btn-sm p-0 text-decoration-none"
|
|
data-bs-toggle="collapse"
|
|
data-bs-target="@($"#{collapseId}")"
|
|
aria-expanded="false">
|
|
Cluster nodes (Akka, gRPC)
|
|
</button>
|
|
<div class="collapse mt-2" id="@collapseId">
|
|
@* Akka node addresses are legacy: no runtime consumer since the
|
|
ClusterClient→gRPC migration. Central dials the gRPC pair below. *@
|
|
@ClusterRow("Node A (legacy)", site.NodeAAddress)
|
|
@ClusterRow("Node B (legacy)", site.NodeBAddress)
|
|
@ClusterRow("gRPC A", site.GrpcNodeAAddress)
|
|
@ClusterRow("gRPC B", site.GrpcNodeBAddress)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
// CentralUI-024: delegates to the shared helper so the claim type stays
|
|
// resolved through JwtTokenService rather than a duplicated magic string.
|
|
private Task<string> GetCurrentUserAsync()
|
|
=> AuthStateProvider.GetCurrentUsernameAsync();
|
|
|
|
private List<Site> _sites = new();
|
|
private Dictionary<int, List<DataConnection>> _siteConnections = new();
|
|
private bool _loading = true;
|
|
private string? _errorMessage;
|
|
|
|
private bool _deploying;
|
|
private string _search = "";
|
|
|
|
private ToastNotification _toast = default!;
|
|
|
|
private IEnumerable<Site> 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 =>
|
|
{
|
|
<div class="row g-1 align-items-center mb-1">
|
|
<div class="col-2 small text-muted">@label</div>
|
|
<div class="col-9">
|
|
<code class="small d-block text-truncate" title="@address">
|
|
@(string.IsNullOrWhiteSpace(address) ? "—" : address)
|
|
</code>
|
|
</div>
|
|
<div class="col-1 text-end">
|
|
@if (!string.IsNullOrWhiteSpace(address))
|
|
{
|
|
<button class="btn btn-link btn-sm p-0"
|
|
@onclick="() => CopyAsync(address!)" title="Copy">📋</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
};
|
|
|
|
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.");
|
|
}
|
|
}
|
|
}
|