Phases 4-6: Complete Central UI — Admin, Design, Deployment, and Operations pages

Phase 4 — Operator/Admin UI:
- Sites, DataConnections, Areas (hierarchical), API Keys (auto-generated) CRUD
- Health Dashboard (live refresh, per-site metrics from CentralHealthAggregator)
- Instance list with filtering/staleness/lifecycle actions
- Deployment status tracking with auto-refresh

Phase 5 — Authoring UI:
- Template authoring with inheritance tree, tabs (attrs/alarms/scripts/compositions)
- Lock indicators, on-demand validation, collision detection
- Shared scripts with syntax check
- External systems, DB connections, notification lists, Inbound API methods

Phase 6 — Deployment Operations UI:
- Staleness indicators, validation gating
- Debug view (instance selection, attribute/alarm live tables)
- Site event log viewer (filters, keyword search, keyset pagination)
- Parked message management, Audit log viewer with JSON state

Shared components: DataTable, ConfirmDialog, ToastNotification, LoadingSpinner, TimestampDisplay
623 tests pass, zero warnings. All Bootstrap 5, clean corporate design.
This commit is contained in:
Joseph Doherty
2026-03-16 21:47:37 -04:00
parent 6ea38faa6f
commit 3b2320bd35
22 changed files with 4821 additions and 32 deletions
@@ -1,8 +1,233 @@
@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
@implements IDisposable
<div class="container mt-4">
<h4>Deployments</h4>
<p class="text-muted">Deployment management will be available in a future phase.</p>
<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>
<span class="text-muted small me-2">Auto-refresh: 10s</span>
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync">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-md-3">
<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-md-3">
<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-md-3">
<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-md-3">
<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>
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>Deployment ID</th>
<th>Instance</th>
<th>Status</th>
<th>Deployed By</th>
<th>Started</th>
<th>Completed</th>
<th>Revision</th>
<th>Error</th>
</tr>
</thead>
<tbody>
@if (_records.Count == 0)
{
<tr>
<td colspan="8" class="text-muted text-center">No deployments recorded.</td>
</tr>
}
@foreach (var record in _pagedRecords)
{
<tr class="@GetRowClass(record.Status)">
<td><code class="small">@record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)]...</code></td>
<td>@GetInstanceName(record.InstanceId)</td>
<td>
<span class="badge @GetStatusBadge(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;"></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"><code>@(record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)])</code></td>
<td class="small text-danger">@(record.ErrorMessage ?? "")</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>
@for (int i = 1; i <= _totalPages; i++)
{
var page = i;
<li class="page-item @(page == _currentPage ? "active" : "")">
<button class="page-link" @onclick="() => GoToPage(page)">@(page)</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 Timer? _refreshTimer;
private int _currentPage = 1;
private int _totalPages;
private const int PageSize = 25;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
_refreshTimer = new Timer(_ =>
{
InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
}
private async Task LoadDataAsync()
{
_loading = _records.Count == 0; // Only show loading on first load
_errorMessage = null;
try
{
_records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync())
.OrderByDescending(r => r.DeployedAt)
.ToList();
// Build instance name lookup
var instances = await TemplateEngineRepository.GetAllInstancesAsync();
_instanceNames = instances.ToDictionary(i => i.Id, i => i.UniqueName);
_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()
{
_refreshTimer?.Dispose();
}
}