Files
ScadaBridge/src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor
T

359 lines
15 KiB
Plaintext

@page "/deployment/deployments"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Deployment
@using ScadaLink.Commons.Entities.Instances
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject IDeploymentManagerRepository DeploymentManagerRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
@inject ScadaLink.DeploymentManager.IDeploymentStatusNotifier DeploymentStatusNotifier
@implements IDisposable
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Deployment Status</h4>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-outline-secondary btn-sm" @onclick="ToggleAutoRefresh"
aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
</div>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else
{
@* Summary cards *@
<div class="row mb-3">
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-warning">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-warning">@_records.Count(r => r.Status == DeploymentStatus.Pending)</h4>
<small class="text-muted">Pending</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-info">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-info">@_records.Count(r => r.Status == DeploymentStatus.InProgress)</h4>
<small class="text-muted">In Progress</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-success">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-success">@_records.Count(r => r.Status == DeploymentStatus.Success)</h4>
<small class="text-muted">Successful</small>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 col-12">
<div class="card border-danger">
<div class="card-body text-center py-2">
<h4 class="mb-0 text-danger">@_records.Count(r => r.Status == DeploymentStatus.Failed)</h4>
<small class="text-muted">Failed</small>
</div>
</div>
</div>
</div>
@if (_records.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-0">No deployments recorded.</p>
</div>
}
else
{
<table class="table table-sm table-striped table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Deployment</th>
<th>Instance</th>
<th>Status</th>
<th>Deployed By</th>
<th>Started</th>
<th>Completed</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var record in _pagedRecords)
{
var rowId = $"deploy-row-{record.DeploymentId}";
var errorCollapseId = $"deploy-err-{record.DeploymentId}";
var isFailed = record.Status == DeploymentStatus.Failed;
var idShort = record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)];
var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)];
<tr id="@rowId" class="@GetRowClass(record.Status)">
<td>
<code class="small">@idShort@(string.IsNullOrEmpty(revShort) ? "" : $"@{revShort}")</code>
</td>
<td>@GetInstanceName(record.InstanceId)</td>
<td>
@if (isFailed)
{
<i class="bi bi-x-circle text-danger me-1" aria-hidden="true"></i>
}
<span class="badge @GetStatusBadge(record.Status)"
aria-label="@($"Deployment status: {record.Status}")">
@record.Status
@if (record.Status == DeploymentStatus.InProgress)
{
<span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"
role="status" aria-label="Deployment in progress"></span>
}
</span>
</td>
<td class="small">@record.DeployedBy</td>
<td class="small">
<TimestampDisplay Value="@record.DeployedAt" />
</td>
<td class="small">
@if (record.CompletedAt.HasValue)
{
<TimestampDisplay Value="@record.CompletedAt.Value" />
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="small text-end">
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage))
{
<button class="btn btn-link btn-sm p-0" type="button"
@onclick="() => ToggleErrorExpansion(record.DeploymentId)"
aria-expanded="@(IsErrorExpanded(record.DeploymentId) ? "true" : "false")"
aria-controls="@errorCollapseId">
@(IsErrorExpanded(record.DeploymentId) ? "Hide error" : "View error")
</button>
}
</td>
</tr>
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage) && IsErrorExpanded(record.DeploymentId))
{
<tr id="@errorCollapseId" class="table-danger">
<td colspan="7">
<div class="small">
<strong>Error:</strong>
<pre class="mb-0 mt-1 small" style="white-space: pre-wrap; word-break: break-word;">@record.ErrorMessage</pre>
</div>
</td>
</tr>
}
}
</tbody>
</table>
}
@if (_totalPages > 1)
{
<nav>
<ul class="pagination pagination-sm justify-content-end">
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage - 1)">Previous</button>
</li>
@foreach (var page in ScadaLink.CentralUI.Components.Shared.PagerWindow.Build(_currentPage, _totalPages))
{
if (page == 0)
{
<li class="page-item disabled">
<span class="page-link">&hellip;</span>
</li>
}
else
{
var p = page;
<li class="page-item @(p == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
</li>
}
}
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>
</li>
</ul>
</nav>
}
}
</div>
@code {
private List<DeploymentRecord> _records = new();
private List<DeploymentRecord> _pagedRecords = new();
private Dictionary<int, string> _instanceNames = new();
private bool _loading = true;
private string? _errorMessage;
private bool _autoRefresh = true;
private readonly HashSet<string> _expandedErrors = new();
private int _currentPage = 1;
private int _totalPages;
private const int PageSize = 25;
// CentralUI-022: IDeploymentStatusNotifier is a process singleton that
// raises StatusChanged on the DeploymentManager service thread. Dispose()
// unsubscribes, but the notifier can read its subscriber list and begin
// invoking OnDeploymentStatusChanged just before this component is disposed.
// The handler then runs against a disposed component and InvokeAsync throws
// ObjectDisposedException as an unobserved fire-and-forget task exception.
// This flag (set first in Dispose()) makes a racing callback no-op, and the
// dispatch swallows the residual ObjectDisposedException — mirroring the
// DebugView (CentralUI-009) and ToastNotification (CentralUI-010) guards.
private volatile bool _disposed;
// CentralUI-006: deployment status updates are push-based, not polled.
// DeploymentManager raises IDeploymentStatusNotifier.StatusChanged on every
// deployment-record status write; this page subscribes to it and reloads,
// and Blazor Server pushes the re-render to the browser over its SignalR
// circuit — satisfying the design's "no polling required" requirement.
// The notifier event is raised on the DeploymentManager service thread, so
// the handler marshals onto the renderer via InvokeAsync.
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
DeploymentStatusNotifier.StatusChanged += OnDeploymentStatusChanged;
}
private void OnDeploymentStatusChanged(ScadaLink.DeploymentManager.DeploymentStatusChange change)
{
// CentralUI-022: a callback racing disposal must not touch the component.
if (_disposed || !_autoRefresh) return;
_ = DispatchReloadAsync();
}
/// <summary>
/// Reloads the deployment table on the renderer's dispatcher, guarded
/// against the component being disposed mid-flight (CentralUI-022):
/// <c>InvokeAsync</c> throws <see cref="ObjectDisposedException"/> once the
/// circuit is gone, and this handler runs fire-and-forget so that exception
/// would otherwise go unobserved on the DeploymentManager thread.
/// </summary>
private async Task DispatchReloadAsync()
{
if (_disposed) return;
try
{
await InvokeAsync(async () =>
{
if (_disposed) return;
await LoadDataAsync();
StateHasChanged();
});
}
catch (ObjectDisposedException)
{
// Component disposed between the guard and the dispatch — ignore.
}
}
private void ToggleAutoRefresh()
{
// When paused, incoming push notifications are ignored; "Refresh" still
// forces a manual reload. No timer is involved either way.
_autoRefresh = !_autoRefresh;
}
private bool IsErrorExpanded(string deploymentId) => _expandedErrors.Contains(deploymentId);
private void ToggleErrorExpansion(string deploymentId)
{
if (!_expandedErrors.Remove(deploymentId))
{
_expandedErrors.Add(deploymentId);
}
}
private async Task LoadDataAsync()
{
_loading = _records.Count == 0; // Only show loading on first load
_errorMessage = null;
try
{
// Build instance lookups first — site scoping (CentralUI-002) filters
// deployment records by the site of their instance.
var instances = await TemplateEngineRepository.GetAllInstancesAsync();
_instanceNames = instances.ToDictionary(i => i.Id, i => i.UniqueName);
var instanceSiteIds = instances.ToDictionary(i => i.Id, i => i.SiteId);
var systemWide = await SiteScope.IsSystemWideAsync();
var permittedSiteIds = systemWide
? null
: await SiteScope.PermittedSiteIdsAsync();
_records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync())
.Where(r => permittedSiteIds == null
|| (instanceSiteIds.TryGetValue(r.InstanceId, out var sid)
&& permittedSiteIds.Contains(sid)))
.OrderByDescending(r => r.DeployedAt)
.ToList();
_totalPages = Math.Max(1, (int)Math.Ceiling(_records.Count / (double)PageSize));
if (_currentPage > _totalPages) _currentPage = 1;
UpdatePage();
}
catch (Exception ex)
{
_errorMessage = $"Failed to load deployments: {ex.Message}";
}
_loading = false;
}
private void GoToPage(int page)
{
if (page < 1 || page > _totalPages) return;
_currentPage = page;
UpdatePage();
}
private void UpdatePage()
{
_pagedRecords = _records
.Skip((_currentPage - 1) * PageSize)
.Take(PageSize)
.ToList();
}
private string GetInstanceName(int instanceId) =>
_instanceNames.GetValueOrDefault(instanceId, $"#{instanceId}");
private static string GetStatusBadge(DeploymentStatus status) => status switch
{
DeploymentStatus.Pending => "bg-warning text-dark",
DeploymentStatus.InProgress => "bg-info text-dark",
DeploymentStatus.Success => "bg-success",
DeploymentStatus.Failed => "bg-danger",
_ => "bg-secondary"
};
private static string GetRowClass(DeploymentStatus status) => status switch
{
DeploymentStatus.Failed => "table-danger",
DeploymentStatus.InProgress => "table-info",
_ => ""
};
public void Dispose()
{
// CentralUI-022: set the guard first so a callback already in flight on
// the DeploymentManager thread no-ops, then unsubscribe so no further
// status change reaches this disposed component.
_disposed = true;
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
}
}