refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
@* 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">
|
||||
@ClusterRow("Node A", site.NodeAAddress)
|
||||
@ClusterRow("Node B", 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user