@@ -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
_connectionHealth =
+ new Dictionary();
+ 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();
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
index e22484e7..c60cf40b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
@@ -109,6 +109,13 @@ public static class ServiceCollectionExtensions
// and the append-only audit trail; the page only SUBMITS commands.
services.AddScoped();
+ // 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();
+
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ConnectionHealthQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ConnectionHealthQueryService.cs
new file mode 100644
index 00000000..48604fd1
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ConnectionHealthQueryService.cs
@@ -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;
+
+///
+/// Default implementation.
+///
+///
+/// Pure projection — no new health plumbing. The
+/// already holds the latest
+/// SiteHealthReport per site (fed by the existing site→central health
+/// transport); this service reads those reports and resolves their connection
+/// NAME keys back to DataConnection.Id using the same site repository the
+/// design page loads from. Health reports key connections by Name, 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").
+///
+public sealed class ConnectionHealthQueryService : IConnectionHealthQueryService
+{
+ private readonly ISiteRepository _siteRepository;
+ private readonly ICentralHealthAggregator _healthAggregator;
+
+ ///
+ /// Initializes the service with the site repository (connection name→id source)
+ /// and the central health aggregator (latest-report source).
+ ///
+ /// Source of sites and their connections.
+ /// Source of the latest per-site health report.
+ public ConnectionHealthQueryService(
+ ISiteRepository siteRepository,
+ ICentralHealthAggregator healthAggregator)
+ {
+ _siteRepository = siteRepository ?? throw new ArgumentNullException(nameof(siteRepository));
+ _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
+ }
+
+ ///
+ public async Task> 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();
+ 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;
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IConnectionHealthQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IConnectionHealthQueryService.cs
new file mode 100644
index 00000000..e6cd37e9
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IConnectionHealthQueryService.cs
@@ -0,0 +1,27 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
+
+///
+/// M9-T25: CentralUI facade that projects the per-site health reports' name-keyed
+/// connection statuses onto a connection-id → map for
+/// the design DataConnections page. Reuses the existing health transport — the
+/// 's
+/// already-aggregated SiteHealthReport.DataConnectionStatuses — and resolves
+/// each report's connection NAME key back to a DataConnection.Id within the
+/// report's own site, so the page can render a live badge per connection node.
+///
+public interface IConnectionHealthQueryService
+{
+ ///
+ /// Returns a snapshot of connection-id → latest known
+ /// 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.
+ ///
+ /// Cancellation token.
+ /// A task resolving to the connection-id → health map.
+ Task> GetConnectionHealthAsync(
+ CancellationToken ct = default);
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs
index 6f8213e4..9d25ebd2 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
+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;
using DataConnectionsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.DataConnections;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
@@ -19,15 +21,26 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
public class DataConnectionsPageTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For();
+ // M9-T25: the design page now injects IConnectionHealthQueryService to render
+ // per-connection live health badges. A substitute defaulting to an empty map
+ // keeps every existing test green (no badge / unknown state when no report).
+ private readonly IConnectionHealthQueryService _healthQuery =
+ Substitute.For();
public DataConnectionsPageTests()
{
Services.AddSingleton(_siteRepo);
+ Services.AddSingleton(_healthQuery);
// Satisfy the page's [Inject] IDialogService — the host that actually
// renders the dialog lives in MainLayout, not in bUnit's render scope.
Services.AddScoped();
AddTestAuth();
+ // Default: no health known. Tests that assert badge state override this.
+ _healthQuery.GetConnectionHealthAsync(Arg.Any())
+ .Returns(Task.FromResult>(
+ new Dictionary()));
+
JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
}
@@ -162,4 +175,38 @@ public class DataConnectionsPageTests : BunitContext
Assert.Contains("/design/connections", routes);
Assert.Contains("/design/data-connections", routes);
}
+
+ [Fact]
+ public void ConnectionNode_RendersLiveHealthBadge_MatchingStatus()
+ {
+ // M9-T25: a connection whose live health is Connected gets the success
+ // health badge next to its node; a Disconnected one gets the danger badge.
+ SeedRepos(
+ sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
+ connections: new[]
+ {
+ new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 },
+ new DataConnection("RTU-9", "Custom", 1) { Id = 200 }
+ });
+ _healthQuery.GetConnectionHealthAsync(Arg.Any())
+ .Returns(Task.FromResult>(
+ new Dictionary
+ {
+ [100] = ConnectionHealth.Connected,
+ [200] = ConnectionHealth.Disconnected,
+ }));
+
+ var cut = Render();
+ FindToggleForLabel(cut, "Plant-A")!.Click();
+
+ // Each connection node carries a status badge keyed by connection id so the
+ // assertion targets the right node regardless of tree ordering.
+ var connectedBadge = cut.Find("[data-test='conn-health-100']");
+ Assert.Contains("bg-success", connectedBadge.GetAttribute("class"));
+ Assert.Contains("Connected", connectedBadge.TextContent);
+
+ var disconnectedBadge = cut.Find("[data-test='conn-health-200']");
+ Assert.Contains("bg-danger", disconnectedBadge.GetAttribute("class"));
+ Assert.Contains("Disconnected", disconnectedBadge.TextContent);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ConnectionHealthQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ConnectionHealthQueryServiceTests.cs
new file mode 100644
index 00000000..a2186610
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/ConnectionHealthQueryServiceTests.cs
@@ -0,0 +1,152 @@
+using NSubstitute;
+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.Messages.Health;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
+
+///
+/// M9-T25: unit tests for , which projects
+/// the site health reports' name-keyed
+/// onto a connection-id → map for the design
+/// DataConnections page. The site repository + health aggregator are substituted so
+/// the name→id resolution + missing-report / unknown-name tolerance are exercised
+/// without any EF Core or cluster traffic.
+///
+public sealed class ConnectionHealthQueryServiceTests
+{
+ private readonly ISiteRepository _siteRepo = Substitute.For();
+ private readonly ICentralHealthAggregator _aggregator = Substitute.For();
+
+ private ConnectionHealthQueryService CreateService() => new(_siteRepo, _aggregator);
+
+ private void SeedRepos(IEnumerable sites, IEnumerable connections)
+ {
+ _siteRepo.GetAllSitesAsync(Arg.Any())
+ .Returns(Task.FromResult>(sites.ToList()));
+ _siteRepo.GetAllDataConnectionsAsync(Arg.Any())
+ .Returns(Task.FromResult>(connections.ToList()));
+ }
+
+ private static SiteHealthState StateWith(
+ string siteIdentifier,
+ params (string Name, ConnectionHealth Health)[] statuses)
+ {
+ var dict = statuses.ToDictionary(s => s.Name, s => s.Health);
+ return new SiteHealthState
+ {
+ SiteId = siteIdentifier,
+ IsOnline = true,
+ LatestReport = MakeReport(siteIdentifier, dict),
+ };
+ }
+
+ private static SiteHealthReport MakeReport(
+ string siteIdentifier,
+ IReadOnlyDictionary statuses) =>
+ new(
+ SiteId: siteIdentifier,
+ SequenceNumber: 1,
+ ReportTimestamp: DateTimeOffset.UtcNow,
+ DataConnectionStatuses: statuses,
+ TagResolutionCounts: new Dictionary(),
+ ScriptErrorCount: 0,
+ AlarmEvaluationErrorCount: 0,
+ StoreAndForwardBufferDepths: new Dictionary(),
+ DeadLetterCount: 0,
+ DeployedInstanceCount: 0,
+ EnabledInstanceCount: 0,
+ DisabledInstanceCount: 0);
+
+ [Fact]
+ public async Task Maps_ConnectionNameStatuses_ToConnectionIds()
+ {
+ var site = new Site("Plant-A", "plant-a") { Id = 1 };
+ var plc1 = new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 };
+ var rtu9 = new DataConnection("RTU-9", "Custom", 1) { Id = 200 };
+ SeedRepos(new[] { site }, new[] { plc1, rtu9 });
+ _aggregator.GetSiteState("plant-a").Returns(
+ StateWith("plant-a",
+ ("PLC-1", ConnectionHealth.Connected),
+ ("RTU-9", ConnectionHealth.Disconnected)));
+
+ var map = await CreateService().GetConnectionHealthAsync();
+
+ Assert.Equal(ConnectionHealth.Connected, map[100]);
+ Assert.Equal(ConnectionHealth.Disconnected, map[200]);
+ Assert.Equal(2, map.Count);
+ }
+
+ [Fact]
+ public async Task MissingReport_YieldsNoEntriesForThatSite()
+ {
+ var site = new Site("Plant-A", "plant-a") { Id = 1 };
+ var plc1 = new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 };
+ SeedRepos(new[] { site }, new[] { plc1 });
+ // No state tracked for the site yet (just-started central / no report).
+ _aggregator.GetSiteState("plant-a").Returns((SiteHealthState?)null);
+
+ var map = await CreateService().GetConnectionHealthAsync();
+
+ Assert.Empty(map);
+ }
+
+ [Fact]
+ public async Task StateWithoutLatestReport_YieldsNoEntries()
+ {
+ var site = new Site("Plant-A", "plant-a") { Id = 1 };
+ var plc1 = new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 };
+ SeedRepos(new[] { site }, new[] { plc1 });
+ // Heartbeat-only state: site is tracked but has not sent a full report.
+ _aggregator.GetSiteState("plant-a").Returns(
+ new SiteHealthState { SiteId = "plant-a", IsOnline = true, LatestReport = null });
+
+ var map = await CreateService().GetConnectionHealthAsync();
+
+ Assert.Empty(map);
+ }
+
+ [Fact]
+ public async Task UnknownConnectionName_IsSkipped()
+ {
+ var site = new Site("Plant-A", "plant-a") { Id = 1 };
+ var plc1 = new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 };
+ SeedRepos(new[] { site }, new[] { plc1 });
+ // Report references a connection name with no matching DataConnection row
+ // (e.g. a connection deleted centrally but still live at the site).
+ _aggregator.GetSiteState("plant-a").Returns(
+ StateWith("plant-a",
+ ("PLC-1", ConnectionHealth.Connected),
+ ("GHOST", ConnectionHealth.Error)));
+
+ var map = await CreateService().GetConnectionHealthAsync();
+
+ Assert.Equal(ConnectionHealth.Connected, map[100]);
+ Assert.Single(map);
+ }
+
+ [Fact]
+ public async Task ResolvesNamesPerSite_NotGloballyAmbiguous()
+ {
+ // Two sites each own a connection named "PLC-1". The name→id resolution
+ // must be scoped to the report's own site so the right id is stamped.
+ var siteA = new Site("Plant-A", "plant-a") { Id = 1 };
+ var siteB = new Site("Plant-B", "plant-b") { Id = 2 };
+ var aPlc = new DataConnection("PLC-1", "OpcUa", 1) { Id = 100 };
+ var bPlc = new DataConnection("PLC-1", "OpcUa", 2) { Id = 200 };
+ SeedRepos(new[] { siteA, siteB }, new[] { aPlc, bPlc });
+ _aggregator.GetSiteState("plant-a").Returns(
+ StateWith("plant-a", ("PLC-1", ConnectionHealth.Connected)));
+ _aggregator.GetSiteState("plant-b").Returns(
+ StateWith("plant-b", ("PLC-1", ConnectionHealth.Connecting)));
+
+ var map = await CreateService().GetConnectionHealthAsync();
+
+ Assert.Equal(ConnectionHealth.Connected, map[100]);
+ Assert.Equal(ConnectionHealth.Connecting, map[200]);
+ }
+}