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

View File

@@ -1,7 +1,195 @@
@page "/monitoring/health"
@attribute [Authorize]
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.HealthMonitoring
@implements IDisposable
@inject ICentralHealthAggregator HealthAggregator
<div class="container mt-4">
<h4>Health Dashboard</h4>
<p class="text-muted">Site health monitoring 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">Health Dashboard</h4>
<div>
<span class="text-muted small me-2">Auto-refresh: @(_autoRefreshSeconds)s</span>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshNow">Refresh Now</button>
</div>
</div>
@if (_siteStates.Count == 0)
{
<div class="alert alert-info">No site health reports received yet.</div>
}
else
{
@* Overview cards *@
<div class="row mb-3">
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<h3 class="mb-0 text-success">@_siteStates.Values.Count(s => s.IsOnline)</h3>
<small class="text-muted">Sites Online</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-danger">
<div class="card-body text-center">
<h3 class="mb-0 text-danger">@_siteStates.Values.Count(s => !s.IsOnline)</h3>
<small class="text-muted">Sites Offline</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body text-center">
<h3 class="mb-0">@_siteStates.Count</h3>
<small class="text-muted">Total Sites</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body text-center">
<h3 class="mb-0">@_siteStates.Values.Sum(s => s.LatestReport?.ScriptErrorCount ?? 0)</h3>
<small class="text-muted">Total Script Errors</small>
</div>
</div>
</div>
</div>
@* Per-site detail *@
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key))
{
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
@if (state.IsOnline)
{
<span class="badge bg-success me-2">Online</span>
}
else
{
<span class="badge bg-danger me-2">Offline</span>
}
<strong>@siteId</strong>
</div>
<small class="text-muted">
Last report: @state.LastReportReceivedAt.LocalDateTime.ToString("HH:mm:ss") | Seq: @state.LastSequenceNumber
</small>
</div>
<div class="card-body">
@if (state.LatestReport != null)
{
var report = state.LatestReport;
<div class="row">
@* Connection Health *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Data Connections</h6>
@if (report.DataConnectionStatuses.Count == 0)
{
<span class="text-muted small">None</span>
}
else
{
@foreach (var (connName, health) in report.DataConnectionStatuses)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@connName</span>
<span class="badge @GetConnectionHealthBadge(health)">@health</span>
</div>
}
}
</div>
@* Error Counts *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Error Counts</h6>
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="small">Script Errors</td>
<td class="text-end">
<span class="@(report.ScriptErrorCount > 0 ? "text-danger fw-bold" : "")">@report.ScriptErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Alarm Eval Errors</td>
<td class="text-end">
<span class="@(report.AlarmEvaluationErrorCount > 0 ? "text-warning fw-bold" : "")">@report.AlarmEvaluationErrorCount</span>
</td>
</tr>
<tr>
<td class="small">Dead Letters</td>
<td class="text-end">
<span class="@(report.DeadLetterCount > 0 ? "text-danger fw-bold" : "")">@report.DeadLetterCount</span>
</td>
</tr>
</tbody>
</table>
</div>
@* S&F Buffer Depths *@
<div class="col-md-4">
<h6 class="text-muted mb-2">Store-and-Forward Buffers</h6>
@if (report.StoreAndForwardBufferDepths.Count == 0)
{
<span class="text-muted small">Empty</span>
}
else
{
@foreach (var (category, depth) in report.StoreAndForwardBufferDepths)
{
<div class="d-flex justify-content-between mb-1">
<span class="small">@category</span>
<span class="badge @(depth > 0 ? "bg-warning text-dark" : "bg-light text-dark")">@depth</span>
</div>
}
}
</div>
</div>
}
else
{
<span class="text-muted">No report data available.</span>
}
</div>
</div>
}
}
</div>
@code {
private IReadOnlyDictionary<string, SiteHealthState> _siteStates = new Dictionary<string, SiteHealthState>();
private Timer? _refreshTimer;
private int _autoRefreshSeconds = 10;
protected override void OnInitialized()
{
RefreshNow();
_refreshTimer = new Timer(_ =>
{
InvokeAsync(() =>
{
RefreshNow();
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
}
private void RefreshNow()
{
_siteStates = HealthAggregator.GetAllSiteStates();
}
private static string GetConnectionHealthBadge(ConnectionHealth health) => health switch
{
ConnectionHealth.Connected => "bg-success",
ConnectionHealth.Connecting => "bg-warning text-dark",
ConnectionHealth.Disconnected => "bg-danger",
_ => "bg-secondary"
};
public void Dispose()
{
_refreshTimer?.Dispose();
}
}