diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor
index e7e96a2..a160fad 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor
@@ -10,7 +10,7 @@
"Ready" or "Healthy" or "Active" => StatusState.Ok,
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing"
or "Stale" or "Degraded" or "Expiring" => StatusState.Warn,
- "Faulted" or "Unavailable" or "Expired" => StatusState.Bad,
+ "Faulted" or "Unavailable" or "Expired" or "Unhealthy" => StatusState.Bad,
_ => StatusState.Idle,
};
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshot.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshot.cs
index 4ed16d0..4a2f68d 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshot.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshot.cs
@@ -1,7 +1,33 @@
+using System.Text.Json.Serialization;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
+///
+/// One dashboard refresh: gateway status, sessions, workers, metrics, faults, API
+/// keys, the effective configuration, and the Galaxy summary.
+///
+///
+/// is excluded from JSON serialization. It is
+/// startup-static, yet it is the largest part of the record, and the only wire this
+/// record crosses is the /hubs/snapshot tick sent every snapshot interval.
+/// Remote clients receive it once per connection instead, as
+/// (or on demand via
+/// ). In-process pages read the
+/// property directly and are unaffected.
+///
+/// When the snapshot was built.
+/// When the gateway process started.
+/// Elapsed time since start.
+/// Aggregate health-check status, or Unknown.
+/// Server assembly version.
+/// Most recent sessions, newest first.
+/// Workers that are not closed.
+/// Gateway counters and gauges.
+/// Recent session and worker faults.
+/// API key summaries (no secrets).
+/// Effective configuration; not serialized on the hub tick.
+/// Galaxy hierarchy cache summary.
public sealed record DashboardSnapshot(
DateTimeOffset GeneratedAt,
DateTimeOffset GatewayStartedAt,
@@ -13,5 +39,5 @@ public sealed record DashboardSnapshot(
IReadOnlyList Metrics,
IReadOnlyList Faults,
IReadOnlyList ApiKeys,
- EffectiveGatewayConfiguration Configuration,
+ [property: JsonIgnore] EffectiveGatewayConfiguration Configuration,
DashboardGalaxySummary Galaxy);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
index 25b2e42..f6b9750 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -14,7 +15,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardSnapshotService : IDashboardSnapshotService
{
- private const string HealthyStatus = "Healthy";
+ ///
+ /// Status shown before the first health evaluation completes, when the health
+ /// evaluation fails, and when no is registered.
+ /// Deliberately not "Healthy": claiming health that was never checked is how the
+ /// dashboard used to show a green gateway status throughout an outage.
+ ///
+ internal const string UnknownStatus = "Unknown";
+
+ /// Upper bound on one health evaluation, so a hung check cannot stall the snapshot tick.
+ private static readonly TimeSpan GatewayStatusRefreshTimeout = TimeSpan.FromSeconds(2);
///
/// Minimum spacing between API key list reads. The list is a SQLite query whose
@@ -39,7 +49,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly int _recentFaultLimit;
private readonly int _recentSessionLimit;
private readonly ILogger _logger;
+ private readonly HealthCheckService? _healthCheckService;
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
+ private readonly SemaphoreSlim _gatewayStatusRefreshGate = new(1, 1);
+ private string _gatewayStatus = UnknownStatus;
private IReadOnlyList _apiKeySummaries = Array.Empty();
private long _apiKeySummariesRefreshedAtTicks = NeverRefreshedTicks;
// The effective configuration is built from IOptions and is startup-static:
@@ -65,6 +78,11 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
/// Gateway configuration options.
/// Provider for current time; defaults to system time.
/// Optional logger for the dashboard snapshot service.
+ ///
+ /// The gateway's registered health checks.
+ /// is the aggregate status of one evaluation per snapshot tick; without it the
+ /// status stays .
+ ///
public DashboardSnapshotService(
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
@@ -73,7 +91,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
IApiKeyAdminStore apiKeyAdminStore,
IOptions options,
TimeProvider? timeProvider = null,
- ILogger? logger = null)
+ ILogger? logger = null,
+ HealthCheckService? healthCheckService = null)
{
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
@@ -88,6 +107,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
_logger = logger ?? NullLogger.Instance;
+ _healthCheckService = healthCheckService;
}
///
@@ -111,7 +131,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
GeneratedAt: generatedAt,
GatewayStartedAt: _gatewayStartedAt,
GatewayUptime: generatedAt - _gatewayStartedAt,
- GatewayStatus: HealthyStatus,
+ GatewayStatus: Volatile.Read(ref _gatewayStatus),
GatewayVersion: typeof(DashboardSnapshotService).Assembly.GetName().Version?.ToString() ?? "unknown",
Sessions: sessionSummaries,
Workers: workerSummaries,
@@ -171,6 +191,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
}
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
+ await RefreshGatewayStatusAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot();
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
@@ -192,10 +213,57 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
}
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
+ await RefreshGatewayStatusAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot();
}
}
+ ///
+ /// Evaluates the registered health checks once and caches the aggregate status
+ /// for , which is synchronous and also serves page seeds
+ /// and hub connects between ticks. One evaluation per tick, not per
+ /// call: the checks include an auth-store query, and the
+ /// tick is already the cadence at which the dashboard refreshes.
+ ///
+ /// Token to cancel the evaluation.
+ /// A task that represents the asynchronous operation.
+ private async Task RefreshGatewayStatusAsync(CancellationToken cancellationToken)
+ {
+ if (_healthCheckService is null)
+ {
+ return;
+ }
+
+ // Zero-timeout gate: a concurrent enumeration (the hub publisher and the page
+ // feed each run one) skips rather than queueing a second evaluation.
+ if (!await _gatewayStatusRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ try
+ {
+ using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(GatewayStatusRefreshTimeout);
+ HealthReport report = await _healthCheckService.CheckHealthAsync(timeout.Token).ConfigureAwait(false);
+ Volatile.Write(ref _gatewayStatus, report.Status.ToString());
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ // A health evaluation that cannot complete is not evidence of health.
+ Volatile.Write(ref _gatewayStatus, UnknownStatus);
+ _logger.LogDebug(exception, "Dashboard gateway health evaluation failed.");
+ }
+ finally
+ {
+ _gatewayStatusRefreshGate.Release();
+ }
+ }
+
private static DashboardSessionSummary CreateSessionSummary(
GatewaySession session,
GatewayMetricsSnapshot metricsSnapshot)
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs
index 4f340be..aa70d7e 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotHub.cs
@@ -1,13 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
///
/// SignalR hub that pushes a fresh on every
-/// snapshot refresh. New connections receive the current snapshot
-/// immediately via ; subsequent refreshes are
-/// broadcast by .
+/// snapshot refresh. New connections receive the effective configuration and the
+/// current snapshot immediately via ; subsequent
+/// refreshes are broadcast by without the
+/// configuration, which is startup-static and serialized only once per connection.
///
///
/// Connections are counted into
@@ -22,13 +24,28 @@ public sealed class DashboardSnapshotHub(
/// Method name used to push snapshot updates to clients.
public const string SnapshotMessage = "SnapshotUpdated";
+ ///
+ /// Method name of the one-per-connection effective-configuration push. Snapshot
+ /// ticks omit the configuration, so a client keeps the copy it got here.
+ ///
+ public const string ConfigurationMessage = "ConfigurationUpdated";
+
+ ///
+ /// Returns the effective gateway configuration — the same object
+ /// carries — for a client that needs it again.
+ ///
+ /// The effective configuration.
+ public EffectiveGatewayConfiguration GetConfiguration() => snapshotService.GetSnapshot().Configuration;
+
///
public override async Task OnConnectedAsync()
{
// Count the viewer before seeding it, so the publisher resumes its tick
// no later than the first snapshot this connection renders.
connectionCounter.Increment();
- await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false);
+ DashboardSnapshot snapshot = snapshotService.GetSnapshot();
+ await Clients.Caller.SendAsync(ConfigurationMessage, snapshot.Configuration).ConfigureAwait(false);
+ await Clients.Caller.SendAsync(SnapshotMessage, snapshot).ConfigureAwait(false);
await base.OnConnectedAsync().ConfigureAwait(false);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs
index 85701b2..3ad8c5f 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardSnapshotPublisher.cs
@@ -26,11 +26,20 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// connected. While the count is zero the publisher does not advance the snapshot
/// enumerator at all, so the producing iterator stays suspended and builds nothing.
///
+///
+/// Each broadcast is bounded by a send timeout (one second by default, the default
+/// snapshot interval). A broadcast completes only once every connection accepted the
+/// frame, so awaiting it unbounded let one slow remote client delay every tick for
+/// everyone; a send that misses the bound is abandoned and the next tick goes out on
+/// time. The tick omits the effective configuration (see
+/// ), so each broadcast is the volatile state only.
+///
///
public sealed class DashboardSnapshotPublisher : BackgroundService
{
private static readonly TimeSpan DefaultReconnectDelay = TimeSpan.FromSeconds(5);
private static readonly TimeSpan DefaultIdlePollInterval = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan DefaultSendTimeout = TimeSpan.FromSeconds(1);
private readonly IDashboardSnapshotService _snapshotService;
private readonly IHubContext _hubContext;
@@ -38,6 +47,7 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
private readonly ILogger _logger;
private readonly TimeSpan _reconnectDelay;
private readonly TimeSpan _idlePollInterval;
+ private readonly TimeSpan _sendTimeout;
/// Initializes a new instance of the DashboardSnapshotPublisher class.
/// The snapshot service to subscribe to.
@@ -64,13 +74,15 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
/// The logger instance.
/// The delay before reconnecting after a subscription failure.
/// How often the idle publisher re-checks for a connected viewer.
+ /// Upper bound on one broadcast; defaults to one second.
internal DashboardSnapshotPublisher(
IDashboardSnapshotService snapshotService,
IHubContext hubContext,
DashboardSnapshotHubConnectionCounter connectionCounter,
ILogger logger,
TimeSpan reconnectDelay,
- TimeSpan idlePollInterval)
+ TimeSpan idlePollInterval,
+ TimeSpan? sendTimeout = null)
{
_snapshotService = snapshotService;
_hubContext = hubContext;
@@ -78,6 +90,7 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
_logger = logger;
_reconnectDelay = reconnectDelay;
_idlePollInterval = idlePollInterval;
+ _sendTimeout = sendTimeout ?? DefaultSendTimeout;
}
///
@@ -117,10 +130,19 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
try
{
- await _hubContext.Clients
- .All
- .SendAsync(DashboardSnapshotHub.SnapshotMessage, snapshot, stoppingToken)
+ bool sent = await BoundedHubSend.SendAsync(
+ _hubContext.Clients.All,
+ DashboardSnapshotHub.SnapshotMessage,
+ snapshot,
+ _sendTimeout,
+ stoppingToken)
.ConfigureAwait(false);
+ if (!sent)
+ {
+ _logger.LogDebug(
+ "Snapshot broadcast exceeded {Timeout} and was abandoned; the next tick proceeds.",
+ _sendTimeout);
+ }
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs
index 02607fc..b983377 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs
@@ -138,6 +138,35 @@ public sealed class DashboardSnapshotPublisherTests
Assert.True(snapshotService.PullCount >= 1);
}
+ ///
+ /// A broadcast that never completes — one remote client whose transport is wedged —
+ /// is abandoned at the send timeout, so later ticks still go out instead of every
+ /// viewer waiting on the slowest one.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task ExecuteAsync_WhenABroadcastNeverCompletes_LaterTicksStillGoOut()
+ {
+ CountingSnapshotService snapshotService = new();
+ RecordingHubContext hubContext = new();
+ hubContext.Proxy.StallFirstSend = true;
+ DashboardSnapshotPublisher publisher = new(
+ snapshotService,
+ hubContext,
+ ConnectedCounter(),
+ NullLogger.Instance,
+ TimeSpan.FromMilliseconds(50),
+ IdlePollInterval,
+ sendTimeout: TimeSpan.FromMilliseconds(50));
+
+ using CancellationTokenSource cts = new();
+ await publisher.StartAsync(cts.Token).WaitAsync(TestTimeout);
+ await WaitUntilAsync(() => hubContext.SendCount >= 3);
+
+ await cts.CancelAsync();
+ await publisher.StopAsync(CancellationToken.None);
+ }
+
/// Creates a connection counter that already has one live viewer.
/// A counter reporting a single connection.
private static DashboardSnapshotHubConnectionCounter ConnectedCounter()
@@ -271,6 +300,9 @@ public sealed class DashboardSnapshotPublisherTests
/// Gets the group manager.
public IGroupManager Groups { get; } = new NoopGroupManager();
+ /// Gets the recording proxy every client lookup resolves to.
+ public RecordingClientProxy Proxy => _clients.AllProxy;
+
/// Gets the number of send calls recorded.
public int SendCount => _clients.AllProxy.SendCount;
}
@@ -329,6 +361,12 @@ public sealed class DashboardSnapshotPublisherTests
{
private int _sendCount;
+ ///
+ /// Gets or sets a value indicating whether the first send never completes and
+ /// ignores its cancellation token — a wedged client transport.
+ ///
+ public bool StallFirstSend { get; set; }
+
/// Gets the number of send calls recorded.
public int SendCount => Volatile.Read(ref _sendCount);
@@ -339,8 +377,8 @@ public sealed class DashboardSnapshotPublisherTests
/// A task that represents the asynchronous operation.
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
- Interlocked.Increment(ref _sendCount);
- return Task.CompletedTask;
+ int count = Interlocked.Increment(ref _sendCount);
+ return StallFirstSend && count == 1 ? new TaskCompletionSource().Task : Task.CompletedTask;
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
index 09efbf6..81b3357 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
@@ -1,4 +1,6 @@
using System.Globalization;
+using System.Text.Json;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
@@ -18,7 +20,8 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardSnapshotServiceTests
{
///
- /// Verifies snapshot returns empty collections and healthy status when registry is empty.
+ /// Verifies snapshot returns empty collections when registry is empty, and an
+ /// Unknown status when no health check service is available to evaluate.
///
[Fact]
public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState()
@@ -32,10 +35,76 @@ public sealed class DashboardSnapshotServiceTests
Assert.Empty(snapshot.Workers);
Assert.Empty(snapshot.Faults);
Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.sessions.open" && metric.Value == 0);
- Assert.Equal("Healthy", snapshot.GatewayStatus);
+ Assert.Equal(DashboardSnapshotService.UnknownStatus, snapshot.GatewayStatus);
Assert.NotNull(snapshot.Configuration);
}
+ ///
+ /// SEC-27: the gateway status is the aggregate of the registered health checks,
+ /// evaluated on the snapshot tick, not a hardcoded "Healthy".
+ ///
+ /// The aggregate status the health checks report.
+ /// A task that represents the asynchronous operation.
+ [Theory]
+ [InlineData(HealthStatus.Healthy)]
+ [InlineData(HealthStatus.Degraded)]
+ [InlineData(HealthStatus.Unhealthy)]
+ public async Task WatchSnapshotsAsync_ReportsTheHealthCheckStatus(HealthStatus status)
+ {
+ using GatewayMetrics metrics = new();
+ StubHealthCheckService health = new(status);
+ DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, healthCheckService: health);
+ using CancellationTokenSource cancellation = new();
+ await using IAsyncEnumerator enumerator = service
+ .WatchSnapshotsAsync(cancellation.Token)
+ .GetAsyncEnumerator(cancellation.Token);
+
+ Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)));
+
+ Assert.Equal(status.ToString(), enumerator.Current.GatewayStatus);
+
+ // Cached for the synchronous seed path too, without a second evaluation.
+ Assert.Equal(status.ToString(), service.GetSnapshot().GatewayStatus);
+ Assert.Equal(1, health.CheckCount);
+ }
+
+ /// A health evaluation that throws is reported as Unknown, never as Healthy.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task WatchSnapshotsAsync_WhenHealthEvaluationThrows_ReportsUnknown()
+ {
+ using GatewayMetrics metrics = new();
+ StubHealthCheckService health = new(HealthStatus.Healthy) { Throw = true };
+ DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, healthCheckService: health);
+ using CancellationTokenSource cancellation = new();
+ await using IAsyncEnumerator enumerator = service
+ .WatchSnapshotsAsync(cancellation.Token)
+ .GetAsyncEnumerator(cancellation.Token);
+
+ Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)));
+
+ Assert.Equal(DashboardSnapshotService.UnknownStatus, enumerator.Current.GatewayStatus);
+ }
+
+ ///
+ /// The startup-static configuration is not part of the serialized snapshot, so the
+ /// hub tick carries only volatile state; the hub sends it once per connection.
+ ///
+ [Fact]
+ public void GetSnapshot_WhenSerialized_OmitsTheConfiguration()
+ {
+ using GatewayMetrics metrics = new();
+ DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics);
+
+ DashboardSnapshot snapshot = service.GetSnapshot();
+ using JsonDocument json = JsonDocument.Parse(JsonSerializer.Serialize(snapshot, JsonSerializerOptions.Web));
+
+ Assert.NotNull(snapshot.Configuration);
+ Assert.False(json.RootElement.TryGetProperty("configuration", out _));
+ Assert.True(json.RootElement.TryGetProperty("gatewayStatus", out _));
+ Assert.True(json.RootElement.TryGetProperty("sessions", out _));
+ }
+
///
/// Verifies snapshot projects active, faulted, and closed session states with worker and metrics data.
///
@@ -615,7 +684,8 @@ public sealed class DashboardSnapshotServiceTests
GatewayOptions? options = null,
IGalaxyHierarchyCache? galaxyHierarchyCache = null,
IApiKeyAdminStore? apiKeyAdminStore = null,
- TimeProvider? timeProvider = null)
+ TimeProvider? timeProvider = null,
+ HealthCheckService? healthCheckService = null)
{
GatewayOptions resolvedOptions = options ?? new GatewayOptions
{
@@ -633,7 +703,38 @@ public sealed class DashboardSnapshotServiceTests
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
Options.Create(resolvedOptions),
- timeProvider);
+ timeProvider,
+ healthCheckService: healthCheckService);
+ }
+
+ private sealed class StubHealthCheckService(HealthStatus status) : HealthCheckService
+ {
+ private int _checkCount;
+
+ /// Gets or sets a value indicating whether the evaluation throws.
+ public bool Throw { get; set; }
+
+ /// Gets the number of evaluations requested.
+ public int CheckCount => Volatile.Read(ref _checkCount);
+
+ ///
+ public override Task CheckHealthAsync(
+ Func? predicate,
+ CancellationToken cancellationToken = default)
+ {
+ Interlocked.Increment(ref _checkCount);
+ if (Throw)
+ {
+ throw new InvalidOperationException("health evaluation failed");
+ }
+
+ return Task.FromResult(new HealthReport(
+ new Dictionary
+ {
+ ["stub"] = new(status, null, TimeSpan.Zero, null, null),
+ },
+ TimeSpan.Zero));
+ }
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache