feat(m9/T25): connection live-status indicators on the design page

This commit is contained in:
Joseph Doherty
2026-06-18 11:03:22 -04:00
parent e3bc19c673
commit efe3ada03d
6 changed files with 393 additions and 0 deletions
@@ -1,10 +1,14 @@
@page "/design/connections"
@page "/design/data-connections"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@implements IDisposable
@inject ISiteRepository SiteRepository
@inject IConnectionHealthQueryService ConnectionHealthQuery
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
@@ -80,6 +84,21 @@
{
<span class="tv-label" style="@labelStyle">@node.Label</span>
<span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
var health = _connectionHealth.TryGetValue(node.Connection!.Id, out var h)
? (ConnectionHealth?)h
: null;
@if (health is { } known)
{
<span class="badge @GetConnectionHealthBadge(known) ms-1"
data-test="@($"conn-health-{node.Connection!.Id}")"
aria-label="@($"Connection health: {known}")">@known</span>
}
else
{
<span class="badge bg-secondary ms-1"
data-test="@($"conn-health-{node.Connection!.Id}")"
aria-label="Connection health: unknown">Unknown</span>
}
}
<span class="tv-meta">
<div class="dropdown dc-node-actions" @onclick:stopPropagation="true">
@@ -184,11 +203,48 @@
private ToastNotification _toast = default!;
// M9-T25: live per-connection health, keyed by DataConnection.Id. Sourced from
// the existing site→central health transport via IConnectionHealthQueryService
// and refreshed on a ~10s poll, mirroring the Health dashboard's timer pattern.
// A connection absent from the map renders an "Unknown" badge (tolerates a site
// with no report yet — no crash).
private IReadOnlyDictionary<int, ConnectionHealth> _connectionHealth =
new Dictionary<int, ConnectionHealth>();
private Timer? _healthTimer;
private const int HealthRefreshSeconds = 10;
private bool HasSiteSelected => ResolveSelectedSiteId() != null;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
await LoadConnectionHealthAsync();
// Poll the live health map on the same cadence as the Health dashboard.
// Best-effort: a failed query leaves the prior map in place rather than
// disturbing the tree.
_healthTimer = new Timer(_ =>
{
InvokeAsync(async () =>
{
await LoadConnectionHealthAsync();
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(HealthRefreshSeconds), TimeSpan.FromSeconds(HealthRefreshSeconds));
}
// Best-effort load of the connection-id → health map. A transient fault leaves
// the existing badges untouched — health is advisory, never blocks the page.
private async Task LoadConnectionHealthAsync()
{
try
{
_connectionHealth = await ConnectionHealthQuery.GetConnectionHealthAsync();
}
catch
{
// Keep the prior map; the next poll retries.
}
}
private async Task LoadDataAsync()
@@ -310,4 +366,22 @@
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
// M9-T25: enum → Bootstrap badge class. Mirrors the Health dashboard's
// GetConnectionHealthBadge (Components/Pages/Monitoring/Health.razor) so the
// design page surfaces the same colour coding for the same status. Kept as a
// small local mirror — the Health helper is a private page-local method, not a
// shared component, so extracting it carries more risk than the duplication.
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()
{
_healthTimer?.Dispose();
}
}
@@ -109,6 +109,13 @@ public static class ServiceCollectionExtensions
// and the append-only audit trail; the page only SUBMITS commands.
services.AddScoped<ISecuredWriteService, SecuredWriteService>();
// Connection live-status (M9-T25): projects the per-site health reports'
// name-keyed connection statuses onto a connection-id → ConnectionHealth map
// so the design DataConnections page can render a live badge per connection
// node. Reuses the existing site→central health transport via
// ICentralHealthAggregator — no new plumbing.
services.AddScoped<IConnectionHealthQueryService, ConnectionHealthQueryService>();
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
@@ -0,0 +1,86 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Default <see cref="IConnectionHealthQueryService"/> implementation.
/// </summary>
/// <remarks>
/// Pure projection — no new health plumbing. The
/// <see cref="ICentralHealthAggregator"/> already holds the latest
/// <c>SiteHealthReport</c> per site (fed by the existing site→central health
/// transport); this service reads those reports and resolves their connection
/// NAME keys back to <c>DataConnection.Id</c> using the same site repository the
/// design page loads from. Health reports key connections by <c>Name</c>, the
/// page renders nodes by id, so the join is per-site Name → Id; doing it per-site
/// avoids cross-site name collisions (two sites can each own a "PLC-1").
/// </remarks>
public sealed class ConnectionHealthQueryService : IConnectionHealthQueryService
{
private readonly ISiteRepository _siteRepository;
private readonly ICentralHealthAggregator _healthAggregator;
/// <summary>
/// Initializes the service with the site repository (connection name→id source)
/// and the central health aggregator (latest-report source).
/// </summary>
/// <param name="siteRepository">Source of sites and their connections.</param>
/// <param name="healthAggregator">Source of the latest per-site health report.</param>
public ConnectionHealthQueryService(
ISiteRepository siteRepository,
ICentralHealthAggregator healthAggregator)
{
_siteRepository = siteRepository ?? throw new ArgumentNullException(nameof(siteRepository));
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<int, ConnectionHealth>> GetConnectionHealthAsync(
CancellationToken ct = default)
{
var sites = await _siteRepository.GetAllSitesAsync(ct);
var connections = await _siteRepository.GetAllDataConnectionsAsync(ct);
// Per-site connection name → id. Ordinal: connection names are case-sensitive
// identifiers and the health report keys are produced from the same names.
var idByName = connections
.GroupBy(c => c.SiteId)
.ToDictionary(
g => g.Key,
g => g
.GroupBy(c => c.Name, StringComparer.Ordinal)
.ToDictionary(n => n.Key, n => n.First().Id, StringComparer.Ordinal));
var result = new Dictionary<int, ConnectionHealth>();
foreach (var site in sites)
{
// Health reports are keyed by the site's machine identifier.
var report = _healthAggregator.GetSiteState(site.SiteIdentifier)?.LatestReport;
if (report is null)
{
// No report yet (just-started central / heartbeat-only): contribute
// nothing — the page renders these connections as "unknown".
continue;
}
if (!idByName.TryGetValue(site.Id, out var siteIds))
{
continue;
}
foreach (var (connName, health) in report.DataConnectionStatuses)
{
// Unknown name (e.g. deleted centrally but still live at the site):
// skip — there is no node to badge.
if (siteIds.TryGetValue(connName, out var connId))
{
result[connId] = health;
}
}
}
return result;
}
}
@@ -0,0 +1,27 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// M9-T25: CentralUI facade that projects the per-site health reports' name-keyed
/// connection statuses onto a connection-id → <see cref="ConnectionHealth"/> map for
/// the design DataConnections page. Reuses the existing health transport — the
/// <see cref="ZB.MOM.WW.ScadaBridge.HealthMonitoring.ICentralHealthAggregator"/>'s
/// already-aggregated <c>SiteHealthReport.DataConnectionStatuses</c> — and resolves
/// each report's connection NAME key back to a <c>DataConnection.Id</c> within the
/// report's own site, so the page can render a live badge per connection node.
/// </summary>
public interface IConnectionHealthQueryService
{
/// <summary>
/// Returns a snapshot of connection-id → latest known <see cref="ConnectionHealth"/>
/// across every configured site. A site with no health report yet (or a
/// heartbeat-only state) contributes no entries; a report referencing a
/// connection name that does not match any configured connection is skipped.
/// Callers treat a missing key as "unknown" rather than a failure.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task resolving to the connection-id → health map.</returns>
Task<IReadOnlyDictionary<int, ConnectionHealth>> GetConnectionHealthAsync(
CancellationToken ct = default);
}