feat(m9/T25): connection live-status indicators on the design page
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
@page "/design/connections"
|
@page "/design/connections"
|
||||||
@page "/design/data-connections"
|
@page "/design/data-connections"
|
||||||
@using ZB.MOM.WW.ScadaBridge.Security
|
@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.Entities.Sites
|
||||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||||
|
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
|
@implements IDisposable
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject IConnectionHealthQueryService ConnectionHealthQuery
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
|
|
||||||
@@ -80,6 +84,21 @@
|
|||||||
{
|
{
|
||||||
<span class="tv-label" style="@labelStyle">@node.Label</span>
|
<span class="tv-label" style="@labelStyle">@node.Label</span>
|
||||||
<span class="badge bg-info ms-2">@node.Connection!.Protocol</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">
|
<span class="tv-meta">
|
||||||
<div class="dropdown dc-node-actions" @onclick:stopPropagation="true">
|
<div class="dropdown dc-node-actions" @onclick:stopPropagation="true">
|
||||||
@@ -184,11 +203,48 @@
|
|||||||
|
|
||||||
private ToastNotification _toast = default!;
|
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;
|
private bool HasSiteSelected => ResolveSelectedSiteId() != null;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await LoadDataAsync();
|
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()
|
private async Task LoadDataAsync()
|
||||||
@@ -310,4 +366,22 @@
|
|||||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
_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.
|
// and the append-only audit trail; the page only SUBMITS commands.
|
||||||
services.AddScoped<ISecuredWriteService, SecuredWriteService>();
|
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.
|
// Roslyn-backed C# analysis for the Monaco script editor.
|
||||||
// Scoped because SharedScriptCatalog wraps a scoped service.
|
// Scoped because SharedScriptCatalog wraps a scoped service.
|
||||||
services.AddMemoryCache(o => o.SizeLimit = 200);
|
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);
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Components.Authorization;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
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.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
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;
|
using DataConnectionsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.DataConnections;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
||||||
@@ -19,15 +21,26 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;
|
|||||||
public class DataConnectionsPageTests : BunitContext
|
public class DataConnectionsPageTests : BunitContext
|
||||||
{
|
{
|
||||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
// 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<IConnectionHealthQueryService>();
|
||||||
|
|
||||||
public DataConnectionsPageTests()
|
public DataConnectionsPageTests()
|
||||||
{
|
{
|
||||||
Services.AddSingleton(_siteRepo);
|
Services.AddSingleton(_siteRepo);
|
||||||
|
Services.AddSingleton(_healthQuery);
|
||||||
// Satisfy the page's [Inject] IDialogService — the host that actually
|
// Satisfy the page's [Inject] IDialogService — the host that actually
|
||||||
// renders the dialog lives in MainLayout, not in bUnit's render scope.
|
// renders the dialog lives in MainLayout, not in bUnit's render scope.
|
||||||
Services.AddScoped<IDialogService, DialogService>();
|
Services.AddScoped<IDialogService, DialogService>();
|
||||||
AddTestAuth();
|
AddTestAuth();
|
||||||
|
|
||||||
|
// Default: no health known. Tests that assert badge state override this.
|
||||||
|
_healthQuery.GetConnectionHealthAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyDictionary<int, ConnectionHealth>>(
|
||||||
|
new Dictionary<int, ConnectionHealth>()));
|
||||||
|
|
||||||
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
|
||||||
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
|
||||||
}
|
}
|
||||||
@@ -162,4 +175,38 @@ public class DataConnectionsPageTests : BunitContext
|
|||||||
Assert.Contains("/design/connections", routes);
|
Assert.Contains("/design/connections", routes);
|
||||||
Assert.Contains("/design/data-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<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyDictionary<int, ConnectionHealth>>(
|
||||||
|
new Dictionary<int, ConnectionHealth>
|
||||||
|
{
|
||||||
|
[100] = ConnectionHealth.Connected,
|
||||||
|
[200] = ConnectionHealth.Disconnected,
|
||||||
|
}));
|
||||||
|
|
||||||
|
var cut = Render<DataConnectionsPage>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+152
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M9-T25: unit tests for <see cref="ConnectionHealthQueryService"/>, which projects
|
||||||
|
/// the site health reports' name-keyed <see cref="SiteHealthReport.DataConnectionStatuses"/>
|
||||||
|
/// onto a connection-id → <see cref="ConnectionHealth"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConnectionHealthQueryServiceTests
|
||||||
|
{
|
||||||
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||||
|
private readonly ICentralHealthAggregator _aggregator = Substitute.For<ICentralHealthAggregator>();
|
||||||
|
|
||||||
|
private ConnectionHealthQueryService CreateService() => new(_siteRepo, _aggregator);
|
||||||
|
|
||||||
|
private void SeedRepos(IEnumerable<Site> sites, IEnumerable<DataConnection> connections)
|
||||||
|
{
|
||||||
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Site>>(sites.ToList()));
|
||||||
|
_siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<DataConnection>>(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<string, ConnectionHealth> statuses) =>
|
||||||
|
new(
|
||||||
|
SiteId: siteIdentifier,
|
||||||
|
SequenceNumber: 1,
|
||||||
|
ReportTimestamp: DateTimeOffset.UtcNow,
|
||||||
|
DataConnectionStatuses: statuses,
|
||||||
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
||||||
|
ScriptErrorCount: 0,
|
||||||
|
AlarmEvaluationErrorCount: 0,
|
||||||
|
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user