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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user