359 lines
15 KiB
Plaintext
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">…</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;
|
|
}
|
|
}
|