+ @onclick="ToggleLogAsync">
EVENT LOG
@_events.Count
@@ -431,7 +434,7 @@
+ @onclick="ToggleAboutAsync">
ABOUT
@@ -737,6 +740,14 @@
}
}
+ else if (_loadError is not null)
+ {
+
+ CHECKER DATA UNAVAILABLE
+ @_loadError
+ TRY AGAIN
+
+ }
else if (_filtered.Count == 0)
{
@@ -990,7 +1001,7 @@
@HealthieIcons.Expand
+ @onclick="ToggleLogAsync">@HealthieIcons.Expand
@if (_events.Count == 0)
diff --git a/src/Healthie.Dashboard/Components/HealthieDashboard.razor.cs b/src/Healthie.Dashboard/Components/HealthieDashboard.razor.cs
index aa8e1f9..124c19c 100644
--- a/src/Healthie.Dashboard/Components/HealthieDashboard.razor.cs
+++ b/src/Healthie.Dashboard/Components/HealthieDashboard.razor.cs
@@ -5,6 +5,7 @@
using Healthie.Dashboard.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
+using Microsoft.JSInterop;
using System.ComponentModel;
using System.Reflection;
@@ -63,10 +64,16 @@ public sealed partial class HealthieDashboard : IAsyncDisposable
private string? _tagFilter;
private bool _isDarkMode = true;
private bool _isLoading = true;
+ private string? _loadError;
private bool _initialized;
private bool _showAbout;
private bool _showLog;
private bool _asCards;
+ private ElementReference _logDialog;
+ private ElementReference _aboutDialog;
+ private IJSObjectReference? _dialogModule;
+ private bool _activateLogDialog;
+ private bool _activateAboutDialog;
///
/// Sectioned by group on open, flat on request.
@@ -184,6 +191,11 @@ private string OverallLabel
{
get
{
+ if (_loadError is not null)
+ {
+ return "STATUS UNAVAILABLE";
+ }
+
var unhealthy = _states.Values.Count(s => HealthOf(s) == PulseCheckerHealth.Unhealthy);
if (unhealthy > 0)
{
@@ -254,7 +266,14 @@ protected override async Task OnInitializedAsync()
{
_states = snapshot.States;
_displayNames = snapshot.DisplayNames;
- MarkLoaded();
+ if (snapshot.LoadError is null)
+ {
+ MarkLoaded();
+ }
+ else
+ {
+ MarkLoadFailed(snapshot.LoadError);
+ }
}
else
{
@@ -269,6 +288,31 @@ protected override async Task OnInitializedAsync()
_clockLoop = RunClockAsync();
}
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if ((!_activateLogDialog || !_showLog) && (!_activateAboutDialog || !_showAbout))
+ {
+ return;
+ }
+
+ _dialogModule ??= await JS.InvokeAsync(
+ "import",
+ "./_content/Healthie.NET.Dashboard/healthie.js");
+
+ if (_activateLogDialog && _showLog)
+ {
+ _activateLogDialog = false;
+ await _dialogModule.InvokeVoidAsync("activateDialog", _logDialog);
+ }
+
+ if (_activateAboutDialog && _showAbout)
+ {
+ _activateAboutDialog = false;
+ await _dialogModule.InvokeVoidAsync("activateDialog", _aboutDialog);
+ }
+ }
+
private async Task LoadAsync()
{
if (!await _loadLock.WaitAsync(TimeSpan.Zero).ConfigureAwait(true))
@@ -278,10 +322,15 @@ private async Task LoadAsync()
try
{
+ _loadError = null;
_states = await DashboardService.GetAllStatesAsync();
_displayNames = await DashboardService.GetDisplayNamesAsync();
MarkLoaded();
}
+ catch (Exception) when (!_disposing.IsCancellationRequested)
+ {
+ MarkLoadFailed("Could not load checker data. Check the state provider and try again.");
+ }
finally
{
_loadLock.Release();
@@ -292,10 +341,35 @@ private async Task LoadAsync()
private void MarkLoaded()
{
_selected ??= _states.Keys.OrderBy(name => name).FirstOrDefault();
+ _loadError = null;
_isLoading = false;
Refresh();
}
+ private void MarkLoadFailed(string message)
+ {
+ _states = [];
+ _displayNames = [];
+ _filtered = [];
+ _selected = null;
+ _isLoading = false;
+ _loadError = message;
+ _overall = Status.Warning;
+ }
+
+ private async Task RetryLoadAsync()
+ {
+ _loadError = null;
+ _isLoading = true;
+
+ await LoadAsync();
+
+ if (_loadError is null)
+ {
+ await SelectAsync(_selected);
+ }
+ }
+
///
/// At the end of prerendering, stashes the states just read on the server and persists only the
/// token that collects them, so the interactive render can match this one without the board
@@ -316,7 +390,7 @@ private void MarkLoaded()
///
private Task PersistHandoffToken()
{
- var token = Handoff.Stash(new DashboardSnapshot(_states, _displayNames));
+ var token = Handoff.Stash(new DashboardSnapshot(_states, _displayNames, _loadError));
PersistedState.PersistAsJson(HandoffTokenKey, token);
return Task.CompletedTask;
@@ -573,14 +647,6 @@ private void OnTagFilterChanged(ChangeEventArgs args)
Refresh();
}
- private async Task OnRowKeyDown(KeyboardEventArgs args, string name)
- {
- if (args.Key is "Enter" or " ")
- {
- await SelectAsync(name);
- }
- }
-
///
/// Selects a checker and reads anything the feature packages can add about it.
///
@@ -601,9 +667,71 @@ private async Task SelectAsync(string? name)
await LoadUptimeAsync(name);
}
- private void ToggleAbout() => _showAbout = !_showAbout;
+ private Task ToggleAboutAsync() => _showAbout ? CloseAboutAsync() : OpenAboutAsync();
+
+ private Task OpenAboutAsync()
+ {
+ _showAbout = true;
+ _activateAboutDialog = true;
- private void ToggleLog() => _showLog = !_showLog;
+ return Task.CompletedTask;
+ }
+
+ private async Task CloseAboutAsync()
+ {
+ if (!_showAbout)
+ {
+ return;
+ }
+
+ if (_dialogModule is not null)
+ {
+ await _dialogModule.InvokeVoidAsync("deactivateDialog", _aboutDialog);
+ }
+
+ _showAbout = false;
+ }
+
+ private Task ToggleLogAsync() => _showLog ? CloseLogAsync() : OpenLogAsync();
+
+ private Task OpenLogAsync()
+ {
+ _showLog = true;
+ _activateLogDialog = true;
+
+ return Task.CompletedTask;
+ }
+
+ private async Task CloseLogAsync()
+ {
+ if (!_showLog)
+ {
+ return;
+ }
+
+ if (_dialogModule is not null)
+ {
+ await _dialogModule.InvokeVoidAsync("deactivateDialog", _logDialog);
+ }
+
+ _showLog = false;
+ }
+
+ private async Task OnAboutDialogKeyDown(KeyboardEventArgs args)
+ {
+ if (args.Key == "Escape")
+ {
+ await CloseAboutAsync();
+ }
+ }
+
+ private async Task OnLogDialogKeyDown(KeyboardEventArgs args)
+ {
+ if (args.Key == "Escape")
+ {
+ await CloseLogAsync();
+ }
+ }
private async Task TogglePinAsync(string name, bool isPinned)
{
@@ -983,6 +1111,18 @@ public async ValueTask DisposeAsync()
_disposing.Dispose();
_loadLock.Dispose();
_persistSubscription.Dispose();
+
+ if (_dialogModule is not null)
+ {
+ try
+ {
+ await _dialogModule.DisposeAsync();
+ }
+ catch (JSDisconnectedException)
+ {
+ // The circuit ended before its module could be released.
+ }
+ }
}
/// What a colour in this dashboard means.
diff --git a/src/Healthie.Dashboard/Services/DashboardStateHandoff.cs b/src/Healthie.Dashboard/Services/DashboardStateHandoff.cs
index 902f632..06bffe2 100644
--- a/src/Healthie.Dashboard/Services/DashboardStateHandoff.cs
+++ b/src/Healthie.Dashboard/Services/DashboardStateHandoff.cs
@@ -71,4 +71,5 @@ public bool TryCollect(string token, out DashboardSnapshot? snapshot)
///
internal sealed record DashboardSnapshot(
Dictionary States,
- Dictionary DisplayNames);
+ Dictionary DisplayNames,
+ string? LoadError);
diff --git a/src/Healthie.Dashboard/Services/HealthieDashboardService.cs b/src/Healthie.Dashboard/Services/HealthieDashboardService.cs
index 88cd57b..af43474 100644
--- a/src/Healthie.Dashboard/Services/HealthieDashboardService.cs
+++ b/src/Healthie.Dashboard/Services/HealthieDashboardService.cs
@@ -30,7 +30,7 @@ public async Task> GetAllStatesAsync(
catch (Exception ex)
{
logger?.LogError(ex, "Failed to retrieve pulse checker states.");
- return new Dictionary();
+ throw;
}
}
@@ -275,7 +275,7 @@ public async Task> GetDisplayNamesAsync(
catch (Exception ex)
{
logger?.LogError(ex, "Failed to retrieve display names.");
- return new Dictionary();
+ throw;
}
}
diff --git a/src/Healthie.Dashboard/wwwroot/healthie.css b/src/Healthie.Dashboard/wwwroot/healthie.css
index f1fd3ea..f751164 100644
--- a/src/Healthie.Dashboard/wwwroot/healthie.css
+++ b/src/Healthie.Dashboard/wwwroot/healthie.css
@@ -359,9 +359,21 @@
background: var(--hpm-panel2);
}
-.hpm-row:focus-visible {
+.hpm-row-select {
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.hpm-row-select:focus-visible {
outline: 2px solid var(--hpm-status);
outline-offset: 2px;
+ border-radius: 4px;
}
.hpm-row--selected {
@@ -378,6 +390,7 @@
.hpm-row-title,
.hpm-row-sub {
+ display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -491,6 +504,31 @@
font-size: 13px;
}
+.hpm-load-error {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 18px 20px;
+ border: 1px solid var(--hpm-warn);
+ border-radius: 10px;
+ background: var(--hpm-warn-bg);
+ color: var(--hpm-text);
+ font-size: 13px;
+}
+
+.hpm-load-error strong {
+ color: var(--hpm-warn);
+ font-family: var(--hpm-mono);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+}
+
+.hpm-load-error span {
+ flex: 1 1 280px;
+ min-width: 0;
+}
+
.hpm-skeleton {
height: 52px;
border-radius: 10px;
@@ -1127,6 +1165,7 @@
/* The toolbar, the empty state and a group are bands, not cells. */
.hpm-list--cards > .hpm-toolbar,
.hpm-list--cards > .hpm-empty,
+.hpm-list--cards > .hpm-load-error,
.hpm-list--cards > .hpm-group,
.hpm-list--cards > .hpm-skeleton {
grid-column: 1 / -1;
diff --git a/src/Healthie.Dashboard/wwwroot/healthie.js b/src/Healthie.Dashboard/wwwroot/healthie.js
index b0221b2..d2a0f1b 100644
--- a/src/Healthie.Dashboard/wwwroot/healthie.js
+++ b/src/Healthie.Dashboard/wwwroot/healthie.js
@@ -6,3 +6,68 @@ export function isTextOverflowing(element) {
if (!element) return false;
return element.scrollHeight > element.clientHeight;
}
+
+const dialogState = new WeakMap();
+
+const focusableSelector = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])'
+].join(',');
+
+function focusableElements(dialog) {
+ return Array.from(dialog.querySelectorAll(focusableSelector))
+ .filter(element => element.getClientRects().length > 0);
+}
+
+export function activateDialog(dialog) {
+ if (!dialog || dialogState.has(dialog)) return;
+
+ const returnFocus = document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+
+ const trapFocus = event => {
+ if (event.key !== 'Tab') return;
+
+ const focusable = focusableElements(dialog);
+ if (focusable.length === 0) {
+ event.preventDefault();
+ dialog.focus();
+ return;
+ }
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ const movingBeforeFirst = event.shiftKey
+ && (document.activeElement === first || !dialog.contains(document.activeElement));
+ const movingAfterLast = !event.shiftKey
+ && (document.activeElement === last || !dialog.contains(document.activeElement));
+
+ if (movingBeforeFirst || movingAfterLast) {
+ event.preventDefault();
+ (movingBeforeFirst ? last : first).focus();
+ }
+ };
+
+ dialogState.set(dialog, { returnFocus, trapFocus });
+ dialog.addEventListener('keydown', trapFocus);
+
+ const first = focusableElements(dialog)[0] ?? dialog;
+ first.focus();
+}
+
+export function deactivateDialog(dialog) {
+ const state = dialog ? dialogState.get(dialog) : null;
+ if (!state) return;
+
+ dialog.removeEventListener('keydown', state.trapFocus);
+ dialogState.delete(dialog);
+
+ if (state.returnFocus?.isConnected) {
+ state.returnFocus.focus();
+ }
+}
diff --git a/tests/Healthie.Tests.E2E/DashboardGroupingTests.cs b/tests/Healthie.Tests.E2E/DashboardGroupingTests.cs
index 12ffb8c..0044e86 100644
--- a/tests/Healthie.Tests.E2E/DashboardGroupingTests.cs
+++ b/tests/Healthie.Tests.E2E/DashboardGroupingTests.cs
@@ -361,10 +361,24 @@ public async Task Dashboard_ExpandingTheEventLog_OpensItAndClosesAgain(ProviderS
await page.GetByRole(AriaRole.Button, new() { Name = "RUN NOW" }).ClickAsync();
await page.Locator(".hpm-log-body .hpm-event").First.WaitForAsync();
- await page.GetByRole(AriaRole.Button, new() { Name = "Expand the event log" }).ClickAsync();
- await Assertions.Expect(page.Locator(".hpm-modal")).ToBeVisibleAsync();
+ var expand = page.GetByRole(AriaRole.Button, new() { Name = "Expand the event log" });
+ await expand.ClickAsync();
+ var dialog = page.GetByRole(AriaRole.Dialog, new() { Name = "Event log" });
+ await Assertions.Expect(dialog).ToBeVisibleAsync();
await Assertions.Expect(page.Locator(".hpm-log-table")).ToBeVisibleAsync();
+ var close = dialog.GetByRole(AriaRole.Button, new() { Name = "Close" });
+ await Assertions.Expect(close).ToBeFocusedAsync();
+ await close.PressAsync("Tab");
+ await Assertions.Expect(close).ToBeFocusedAsync();
+
+ await close.PressAsync("Escape");
+ await Assertions.Expect(dialog).Not.ToBeVisibleAsync();
+ await Assertions.Expect(expand).ToBeFocusedAsync();
+
+ await expand.ClickAsync();
+ await Assertions.Expect(dialog).ToBeVisibleAsync();
+
// Dismissed from the dimmed edge, where a person clicks. A default click lands in the
// middle of the backdrop, which is precisely where the modal is.
await page.Locator(".hpm-backdrop").ClickAsync(new() { Position = new() { X = 8, Y = 8 } });
@@ -381,7 +395,8 @@ public async Task Dashboard_LegendAndAbout_OpensBehindTheHeaderIconRatherThanTak
await Assertions.Expect(page.Locator(".hpm-popover")).Not.ToBeVisibleAsync();
- await page.GetByRole(AriaRole.Button, new() { Name = "Legend and about" }).ClickAsync();
+ var trigger = page.GetByRole(AriaRole.Button, new() { Name = "Legend and about" }).First;
+ await trigger.ClickAsync();
var about = page.GetByRole(AriaRole.Dialog, new() { Name = "Legend and about" });
await Assertions.Expect(about).ToBeVisibleAsync();
@@ -392,6 +407,17 @@ public async Task Dashboard_LegendAndAbout_OpensBehindTheHeaderIconRatherThanTak
// assertion also passes when the element is simply missing; a version-shaped one cannot.
await Assertions.Expect(about.Locator(".hpm-popover-head .hpm-tag"))
.ToHaveTextAsync(new Regex(@"^v\d+\.\d+"));
+
+ var close = about.GetByRole(AriaRole.Button, new() { Name = "Close" });
+ await Assertions.Expect(close).ToBeFocusedAsync();
+ await close.PressAsync("Shift+Tab");
+ await Assertions.Expect(about.GetByRole(AriaRole.Link, new() { Name = "MIT" })).ToBeFocusedAsync();
+ await page.Keyboard.PressAsync("Tab");
+ await Assertions.Expect(close).ToBeFocusedAsync();
+
+ await close.PressAsync("Escape");
+ await Assertions.Expect(about).Not.ToBeVisibleAsync();
+ await Assertions.Expect(trigger).ToBeFocusedAsync();
browser.AssertNoErrors(page);
}
diff --git a/tests/Healthie.Tests.E2E/DashboardTests.cs b/tests/Healthie.Tests.E2E/DashboardTests.cs
index 92d6595..d3bfb88 100644
--- a/tests/Healthie.Tests.E2E/DashboardTests.cs
+++ b/tests/Healthie.Tests.E2E/DashboardTests.cs
@@ -82,6 +82,25 @@ internal static Task WaitForTheBoardAsync(IPage page) =>
/// Closes the pages this test opened, keeping a trace behind if it failed.
public async ValueTask DisposeAsync() => await browser.FinishCurrentTestAsync();
+ [Fact]
+ public async Task InitialDocument_PinsDashboardStylesBeforeInteractiveHeadConnects()
+ {
+ await using var app = await SampleApp.StartAsync(new ProviderSetup("Timer", UseCosmos: false), Ct);
+ using var client = new HttpClient();
+
+ var initialDocument = await client.GetStringAsync(app.DashboardUrl, Ct);
+
+ Assert.Contains(
+ "href=\"_content/Healthie.NET.Dashboard/healthie.css\" rel=\"stylesheet\" data-healthie-static-styles",
+ initialDocument);
+
+ using var stylesheet = await client.GetAsync(
+ $"{app.BaseUrl}/_content/Healthie.NET.Dashboard/healthie.css",
+ Ct);
+ stylesheet.EnsureSuccessStatusCode();
+ Assert.Equal("text/css", stylesheet.Content.Headers.ContentType?.MediaType);
+ }
+
[Theory]
[MemberData(nameof(Setups))]
public async Task Dashboard_ListsEveryChecker_AndReportsNoBrowserErrors(ProviderSetup setup)
@@ -118,9 +137,17 @@ public async Task SelectingAChecker_ShowsItsDetail(ProviderSetup setup)
await using var app = await SampleApp.StartAsync(setup, Ct);
var page = await OpenDashboardAsync(app);
- await RowFor(page, TargetChecker).ClickAsync();
+ // The row exposes one real selection button beside its independent action buttons. A div
+ // pretending to be a button would contain those buttons, which is invalid interactive
+ // nesting and gives keyboard and screen-reader users an ambiguous control tree.
+ await Assertions.Expect(page.Locator(".hpm-row[role='button']")).ToHaveCountAsync(0);
+ var select = RowFor(page, TargetChecker)
+ .GetByRole(AriaRole.Button, new() { Name = $"View {TargetChecker} details" });
+ await select.FocusAsync();
+ await select.PressAsync("Enter");
await Assertions.Expect(page.Locator(".hpm-sel-name")).ToHaveTextAsync(TargetChecker);
+ await Assertions.Expect(select).ToHaveAttributeAsync("aria-pressed", "true");
// A count would be a timing assertion, not a detail one: the sample installs the uptime
// package, whose 24H cell appears once a segment has been recorded and whose WORST cell
diff --git a/tests/Healthie.Tests.E2E/SampleAppFixture.cs b/tests/Healthie.Tests.E2E/SampleAppFixture.cs
index aaca953..9f247c7 100644
--- a/tests/Healthie.Tests.E2E/SampleAppFixture.cs
+++ b/tests/Healthie.Tests.E2E/SampleAppFixture.cs
@@ -87,15 +87,15 @@ public static async Task StartAsync(
{
var port = FreePort();
var url = $"http://127.0.0.1:{port}";
+ var sampleProjectDirectory = SampleProjectDirectory();
var info = new ProcessStartInfo("dotnet")
{
- // --no-build: the project reference already built the sample, and rebuilding here would
- // race the other setups in this collection over the same output files.
- // --no-launch-profile: launchSettings.json pins an applicationUrl, and it takes
- // precedence over ASPNETCORE_URLS -- so without this the app ignores the free port
- // picked above, every setup fights over one port, and the wait below times out.
- Arguments = $"run --project \"{SampleProjectPath()}\" --configuration {Configuration} --no-build --no-launch-profile",
+ // The project reference already built the sample. Launching that output directly avoids
+ // both concurrent rebuilds and SDK selection through a repository-level global.json.
+ // Launch profiles are not involved, so ASPNETCORE_URLS below selects the free port.
+ Arguments = $"\"{SampleAssemblyPath(sampleProjectDirectory)}\"",
+ WorkingDirectory = sampleProjectDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@@ -165,7 +165,7 @@ private async Task WaitUntilReadyAsync(CancellationToken cancellationToken)
"Release";
#endif
- private static string SampleProjectPath()
+ private static string SampleProjectDirectory()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Healthie.NET.sln")))
@@ -175,9 +175,17 @@ private static string SampleProjectPath()
return dir is null
? throw new InvalidOperationException("Could not locate the repository root from " + AppContext.BaseDirectory)
- : Path.Combine(dir.FullName, "samples", "Healthie.Sample.BlazorUI", "Healthie.Sample.BlazorUI.csproj");
+ : Path.Combine(dir.FullName, "samples", "Healthie.Sample.BlazorUI");
}
+ private static string SampleAssemblyPath(string sampleProjectDirectory) =>
+ Path.Combine(
+ sampleProjectDirectory,
+ "bin",
+ Configuration,
+ "net8.0",
+ "Healthie.Sample.BlazorUI.dll");
+
private static int FreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
diff --git a/tests/Healthie.Tests.Unit/DashboardComponentLifecycleTests.cs b/tests/Healthie.Tests.Unit/DashboardComponentLifecycleTests.cs
index 83a1d94..7965831 100644
--- a/tests/Healthie.Tests.Unit/DashboardComponentLifecycleTests.cs
+++ b/tests/Healthie.Tests.Unit/DashboardComponentLifecycleTests.cs
@@ -6,6 +6,7 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
+using System.Text.Json;
namespace Healthie.Tests.Unit;
@@ -53,6 +54,52 @@ public override Task> GetDisplayNamesAsync(
Task.FromResult(new Dictionary(StringComparer.Ordinal));
}
+ private sealed class FailsFirstLoadDashboardService : StubDashboardService
+ {
+ private int _loads;
+
+ public int Loads => Volatile.Read(ref _loads);
+
+ public override Task SubscribeToStateChangesAsync(
+ Func onStateChanged,
+ CancellationToken cancellationToken = default) =>
+ Task.CompletedTask;
+
+ public override Task UnsubscribeFromStateChangesAsync(
+ Func onStateChanged,
+ CancellationToken cancellationToken = default) =>
+ Task.CompletedTask;
+
+ public override Task> GetAllStatesAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (Interlocked.Increment(ref _loads) == 1)
+ {
+ throw new InvalidOperationException("Simulated state-provider outage.");
+ }
+
+ return Task.FromResult(new Dictionary(StringComparer.Ordinal));
+ }
+
+ public override Task> GetDisplayNamesAsync(
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new Dictionary(StringComparer.Ordinal));
+ }
+
+ private sealed class RestoreOnlyStateStore(string key, string value) : IPersistentComponentStateStore
+ {
+ public Task> GetPersistedStateAsync() =>
+ Task.FromResult>(new Dictionary
+ {
+ [key] = JsonSerializer.SerializeToUtf8Bytes(value),
+ });
+
+ public Task PersistStateAsync(IReadOnlyDictionary state) =>
+ throw new NotSupportedException();
+
+ public bool SupportsRenderMode(IComponentRenderMode renderMode) => true;
+ }
+
private readonly BunitContext _context = new();
private readonly RecordingDashboardService _service = new();
@@ -121,4 +168,49 @@ public async Task NavigatingAwayAndBack_LeavesOneSubscriptionBehindNotTwo()
await second.Instance.DisposeAsync();
Assert.Equal(_service.Subscribed.Count, _service.Unsubscribed.Count);
}
+
+ [Fact]
+ public void FailedInitialLoad_ShowsARecoverableErrorInsteadOfAnEmptyBoard()
+ {
+ var service = new FailsFirstLoadDashboardService();
+ _context.Services.AddSingleton(service);
+
+ var rendered = _context.Render();
+
+ rendered.WaitForAssertion(() =>
+ {
+ Assert.Contains("STATUS UNAVAILABLE", rendered.Markup);
+ Assert.Contains("Could not load checker data", rendered.Markup);
+ Assert.DoesNotContain("No pulse checkers registered", rendered.Markup);
+ });
+
+ rendered.Find(".hpm-load-error button").Click();
+
+ rendered.WaitForAssertion(() =>
+ {
+ Assert.DoesNotContain("Could not load checker data", rendered.Markup);
+ Assert.Contains("No pulse checkers registered", rendered.Markup);
+ Assert.Equal(2, service.Loads);
+ });
+ }
+
+ [Fact]
+ public async Task FailedPrerenderSnapshot_RemainsAnErrorWhenTheInteractiveInstanceCollectsIt()
+ {
+ const string message = "Could not load checker data. Check the state provider and try again.";
+ var handoff = _context.Services.GetRequiredService();
+ var token = handoff.Stash(new DashboardSnapshot([], [], message));
+ var persistence = _context.Services.GetRequiredService();
+ await persistence.RestoreStateAsync(
+ new RestoreOnlyStateStore("healthie.dashboard.handoff", token));
+
+ var rendered = _context.Render();
+
+ rendered.WaitForAssertion(() =>
+ {
+ Assert.Contains("STATUS UNAVAILABLE", rendered.Markup);
+ Assert.Contains(message, rendered.Markup);
+ Assert.DoesNotContain("No pulse checkers registered", rendered.Markup);
+ });
+ }
}