Merge pull request 'feat(164): guided remediation for health checks' (#430) from feat/164-health-checks-ux into main
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m1s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m5s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m0s

This commit was merged in pull request #430.
This commit is contained in:
2026-07-17 23:12:19 +00:00
30 changed files with 400 additions and 44 deletions
@@ -26,4 +26,10 @@
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ErsatzTV.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
+16 -1
View File
@@ -10,7 +10,11 @@ internal static class Mapper
result.Title,
GetStatus(result.Status),
result.Message,
result.Link.MatchUnsafe(l => l.Link, () => null));
string.IsNullOrWhiteSpace(result.BriefMessage) ? null : result.BriefMessage,
result.Link.MatchUnsafe(l => l.Target, () => (string)null),
result.Link.MatchUnsafe(
l => new HealthCheckRemediationResponseModel(GetLinkKind(l.Kind), l.Target),
() => (HealthCheckRemediationResponseModel)null));
private static string GetStatus(HealthCheckStatus status) =>
status switch
@@ -19,6 +23,17 @@ internal static class Mapper
HealthCheckStatus.Fail => "fail",
HealthCheckStatus.Warning => "warn",
HealthCheckStatus.Info => "info",
// NotApplicable is filtered out before mapping today; map it defensively rather
// than throwing, so a future caller that skips the filter can't 500 the endpoint.
HealthCheckStatus.NotApplicable => "notApplicable",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
private static string GetLinkKind(HealthCheckLinkKind kind) =>
kind switch
{
HealthCheckLinkKind.ExternalDoc => "ExternalDoc",
HealthCheckLinkKind.AppRoute => "AppRoute",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
@@ -5,4 +5,14 @@ public record HealthCheckResponseModel(
string Title,
string Status,
string Detail,
string? Link);
string? Brief,
// Deprecated: the raw remediation target, kept for back-compat. Prefer `Remediation`,
// which also carries the kind (external doc vs in-app route). Still populated.
string? Link,
HealthCheckRemediationResponseModel? Remediation);
// Where the user should go to investigate or fix the check. `Kind` is "ExternalDoc"
// (open `Target` as an external URL) or "AppRoute" (navigate to the `Target` /app path).
public record HealthCheckRemediationResponseModel(
string Kind,
string Target);
+9 -1
View File
@@ -1,3 +1,11 @@
namespace ErsatzTV.Core.Health;
public record HealthCheckLink(string Link);
// A remediation link attached to a health-check result: where the user should go to
// investigate or fix the reported problem, and whether that is an external doc or an
// in-app SPA route. `Target` is the URL (ExternalDoc) or the /app path (AppRoute).
public record HealthCheckLink(string Target, HealthCheckLinkKind Kind)
{
public static HealthCheckLink ExternalDoc(string url) => new(url, HealthCheckLinkKind.ExternalDoc);
public static HealthCheckLink AppRoute(string path) => new(path, HealthCheckLinkKind.AppRoute);
}
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Health;
public enum HealthCheckLinkKind
{
// Opens an external documentation page in a new tab.
ExternalDoc = 0,
// Navigates to an in-app SPA route (an /app/... path).
AppRoute = 1
}
@@ -16,7 +16,7 @@ public class DowngradeHealthCheck(IDatabaseMigrations databaseMigrations) : Base
return FailResult(
"Downgrade detected; THIS IS NOT SUPPORTED AND WILL IMPACT STABILITY",
"Downgrade detected",
new HealthCheckLink("https://ersatztv.org/docs/installation/#downgrading"));
HealthCheckLink.ExternalDoc("https://ersatztv.org/docs/installation/#downgrading"));
}
return NotApplicableResult();
@@ -40,7 +40,8 @@ public class EmptyScheduleHealthCheck(IDbContextFactory<TvContext> dbContextFact
return WarningResult(
$"There are {names.Count} empty schedules in use, which are NOT supported and WILL cause errors, including: {string.Join(", ", names.OrderBy(identity))}",
$"There are {names.Count} empty schedules in use, which are NOT supported and WILL cause errors");
$"There are {names.Count} empty schedules in use, which are NOT supported and WILL cause errors",
HealthCheckLink.AppRoute("/app/schedules"));
}
return OkResult();
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Data;
@@ -39,7 +39,8 @@ public class EpisodeMetadataHealthCheck : BaseHealthCheck, IEpisodeMetadataHealt
return WarningResult(
$"There are {episodes.Count} episodes with missing metadata, including in the following folders: {folders}",
$"There are {episodes.Count} episodes with missing metadata");
$"There are {episodes.Count} episodes with missing metadata",
HealthCheckLink.AppRoute("/app/libraries"));
}
return OkResult();
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -23,10 +23,10 @@ public class FFmpegReportsHealthCheck : BaseHealthCheck, IFFmpegReportsHealthChe
{
if (value)
{
return Result(
HealthCheckStatus.Warning,
"FFmpeg troubleshooting reports are enabled and may use a lot of disk space",
"FFmpeg troubleshooting reports are enabled");
return WarningResult(
"FFmpeg troubleshooting reports are enabled and may use a lot of disk space; disable them in Settings when you are done troubleshooting",
"FFmpeg troubleshooting reports are enabled",
HealthCheckLink.AppRoute("/app/settings"));
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -19,7 +19,7 @@ public class FFmpegVersionHealthCheck(
public async Task<HealthCheckResult> Check(CancellationToken cancellationToken)
{
var link = new HealthCheckLink("https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/tag/7.1.1");
var link = HealthCheckLink.ExternalDoc("https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/tag/7.1.1");
Option<ConfigElement> maybeFFmpegPath =
await configElementRepository.GetConfigElement(ConfigElementKey.FFmpegPath, cancellationToken);
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
@@ -62,7 +62,7 @@ public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck
return WarningResult(
$"There are {count} items that do not exist on disk, including the following: {files}",
$"There are {count} items that do not exist on disk",
new HealthCheckLink("media/trash"));
HealthCheckLink.AppRoute("/app/trash"));
}
return OkResult();
@@ -1,4 +1,4 @@
using System.Reflection;
using System.Reflection;
using System.Runtime.InteropServices;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
@@ -97,7 +97,8 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
var channels = string.Join(", ", badChannels.Map(c => $"{c.Number} - {c.Name}"));
return WarningResult(
$"The following channels use ffmpeg profiles that are not configured for hardware acceleration ({accel}): {channels}",
$"{badChannels.Count} channels are not configured for hardware acceleration");
$"{badChannels.Count} channels are not configured for hardware acceleration",
HealthCheckLink.AppRoute("/app/ffmpeg-profiles"));
}
return None;
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Data;
@@ -39,7 +39,8 @@ public class MovieMetadataHealthCheck : BaseHealthCheck, IMovieMetadataHealthChe
return WarningResult(
$"There are {movies.Count} movies with missing metadata, including in the following folders: {folders}",
$"There are {movies.Count} movies with missing metadata");
$"There are {movies.Count} movies with missing metadata",
HealthCheckLink.AppRoute("/app/libraries"));
}
return OkResult();
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
@@ -87,7 +87,7 @@ public class UnavailableHealthCheck : BaseHealthCheck, IUnavailableHealthCheck
return WarningResult(
$"There are {count} items that are unavailable because ErsatzTV cannot find them on disk, including the following: {files}",
$"There are {count} items that are unavailable",
new HealthCheckLink("search?query=state%3aUnavailable"));
HealthCheckLink.AppRoute("/app/search?query=state%3aUnavailable"));
}
return OkResult();
@@ -1,4 +1,4 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Health;
@@ -73,7 +73,8 @@ public class VaapiDriverHealthCheck(
{
return FailResult(
$"FFmpeg Profile {profile.Name} is using device and driver combination ({profile.VaapiDevice} and {profile.VaapiDriver}) that reports no capabilities. Hardware Acceleration WILL NOT WORK as configured.",
"Hardware acceleration WILL NOT WORK...");
"Hardware acceleration WILL NOT WORK...",
HealthCheckLink.AppRoute("/app/ffmpeg-profiles"));
}
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
@@ -65,7 +65,8 @@ public class ZeroDurationHealthCheck : BaseHealthCheck, IZeroDurationHealthCheck
return WarningResult(
$"There are {all.Count} files with zero duration, including the following: {files}",
$"There are {all.Count} files with zero duration");
$"There are {all.Count} files with zero duration",
HealthCheckLink.AppRoute("/app/libraries"));
}
return OkResult();
@@ -60,7 +60,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
}
[Test]
public async Task Should_Include_Link_When_Present()
public async Task Should_Include_ExternalDoc_Remediation_When_Present()
{
var results = new List<HealthCheckResult>
{
@@ -69,7 +69,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
HealthCheckStatus.Warning,
"detail message",
"brief",
Option<HealthCheckLink>.Some(new HealthCheckLink("https://example.com/docs")))
Option<HealthCheckLink>.Some(HealthCheckLink.ExternalDoc("https://example.com/docs")))
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
@@ -77,12 +77,41 @@ public class GetAllHealthCheckResultsForApiHandlerTests
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
// deprecated Link mirrors the remediation target for back-compat
response[0].Link.ShouldBe("https://example.com/docs");
response[0].Detail.ShouldBe("detail message");
response[0].Brief.ShouldBe("brief");
response[0].Remediation.ShouldNotBeNull();
response[0].Remediation!.Kind.ShouldBe("ExternalDoc");
response[0].Remediation!.Target.ShouldBe("https://example.com/docs");
}
[Test]
public async Task Should_Have_Null_Link_When_Absent()
public async Task Should_Include_AppRoute_Remediation_When_Present()
{
var results = new List<HealthCheckResult>
{
new(
"Routed Check",
HealthCheckStatus.Warning,
"detail message",
"brief",
Option<HealthCheckLink>.Some(HealthCheckLink.AppRoute("/app/trash")))
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response[0].Link.ShouldBe("/app/trash");
response[0].Remediation.ShouldNotBeNull();
response[0].Remediation!.Kind.ShouldBe("AppRoute");
response[0].Remediation!.Target.ShouldBe("/app/trash");
}
[Test]
public async Task Should_Have_Null_Link_And_Remediation_When_Absent()
{
var results = new List<HealthCheckResult>
{
@@ -95,6 +124,23 @@ public class GetAllHealthCheckResultsForApiHandlerTests
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response[0].Link.ShouldBeNull();
response[0].Remediation.ShouldBeNull();
}
[Test]
public async Task Should_Map_Empty_BriefMessage_To_Null_Brief()
{
var results = new List<HealthCheckResult>
{
new("Blank Brief", HealthCheckStatus.Pass, "detail", string.Empty, Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response[0].Brief.ShouldBeNull();
}
[Test]
@@ -0,0 +1,42 @@
using ErsatzTV.Application.Health;
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Health;
[TestFixture]
public class HealthMapperTests
{
[Test]
public void ProjectToResponseModel_Should_Map_NotApplicable_Without_Throwing()
{
// The handler filters NotApplicable before mapping today, but the mapper must be
// total so a future caller that skips the filter can't 500 the endpoint (#164).
var result = new HealthCheckResult(
"NA Check",
HealthCheckStatus.NotApplicable,
"skip",
"skip",
Option<HealthCheckLink>.None);
HealthCheckResponseModel model = Should.NotThrow(() => Mapper.ProjectToResponseModel(result));
model.Status.ShouldBe("notApplicable");
}
[Test]
[TestCase(HealthCheckStatus.Pass, "pass")]
[TestCase(HealthCheckStatus.Fail, "fail")]
[TestCase(HealthCheckStatus.Warning, "warn")]
[TestCase(HealthCheckStatus.Info, "info")]
[TestCase(HealthCheckStatus.NotApplicable, "notApplicable")]
public void ProjectToResponseModel_Should_Map_Every_Status(HealthCheckStatus status, string expected)
{
var result = new HealthCheckResult("Check", status, "m", "b", Option<HealthCheckLink>.None);
Mapper.ProjectToResponseModel(result).Status.ShouldBe(expected);
}
}
@@ -40,8 +40,14 @@ public class HealthControllerTests
{
var expected = new List<HealthCheckResponseModel>
{
new("Check One", "pass", "all good", null),
new("Check Two", "fail", "broken", "https://example.com")
new("Check One", "pass", "all good", null, null, null),
new(
"Check Two",
"fail",
"broken",
"broken",
"https://example.com",
new HealthCheckRemediationResponseModel("ExternalDoc", "https://example.com"))
};
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
+34 -1
View File
@@ -26562,12 +26562,29 @@
}
}
},
"HealthCheckRemediationResponseModel": {
"required": [
"kind",
"target"
],
"type": "object",
"properties": {
"kind": {
"type": "string"
},
"target": {
"type": "string"
}
}
},
"HealthCheckResponseModel": {
"required": [
"title",
"status",
"detail",
"link"
"brief",
"link",
"remediation"
],
"type": "object",
"properties": {
@@ -26580,11 +26597,27 @@
"detail": {
"type": "string"
},
"brief": {
"type": [
"null",
"string"
]
},
"link": {
"type": [
"null",
"string"
]
},
"remediation": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/HealthCheckRemediationResponseModel"
}
]
}
}
},
+7
View File
@@ -96,6 +96,13 @@ Exemplars:
the action; filter server-side only when it has a value. Exemplar: `?fillerKind=` on
`GET /api/v1/filler-presets` (`GetAllFillerPresetsForApi(FillerKind? FillerKind = null)`). An invalid
enum value is rejected by model binding (400) — no handler-side guard needed.
- **Evolving a frozen DTO: deprecate-in-place, add the richer field, never remove.** `/api/v1` is
frozen-additive (#286), so when a response field's shape needs to grow, keep the old member
populated (mark it deprecated in an XML/`//` comment) and add the replacement alongside. Exemplar:
`HealthCheckResponseModel` (#164) kept flat `string? Link` (still populated) and added
`Remediation { Kind, Target }` (a nested model with an in-app-route-vs-external-doc kind) plus
`Brief`. `Remediation.Kind` is a mapped **string** ("ExternalDoc"/"AppRoute"), not a wire enum —
same pattern as `Status`. See `decisions.md` 2026-07-17 (#164).
## 3. Error mapping
+4 -1
View File
@@ -306,7 +306,10 @@ The Blazor home page (`/system/health`, formerly `Index.razor`) was the intentio
phase (b). It is now **deleted with the rest of Blazor** in this removal PR. `/system/health` has no
SPA equivalent and no explicit redirect entry, so it falls through to the Startup **catch-all fallback**
→ 302 `/app`. The "Classic UI" link that pointed to it is gone (its host chrome was part of the deleted
Blazor `Shared/**`).
Blazor `Shared/**`). **Correction (#164, 2026-07-17):** one leftover `<a href="/system/health">Open
Classic UI</a>` row survived in the SPA `SettingsScreen` (System pane) — a dead link that just
bounced to `/app`. It was removed here (its regression test now asserts absence); blocks/decos/
templates/playout editors all live in the SPA now, so there is nothing left to escape to.
| Blazor route | Former file | Now |
|---|---|---|
+43
View File
@@ -91,6 +91,7 @@ in-file entries.
- [2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)](#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72)
- [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70)
- [2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)](#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385)
- [2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)](#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164)
---
@@ -1603,3 +1604,45 @@ shipped here; the rest is deliberately deferred rather than forced.
materialization — neither is the additive plumbing this slice was scoped to, and it is a channel-wide
capability rather than an auto-tune concern. Left for its own issue / the SPA branding work; the uploaded
`logo` above already covers the on-screen bug for channels that supply an image.
## 2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)
#164 asked to make the ~14 health checks *actionable* — the Dashboard health panel showed problems
with no way to investigate or fix them. Two structural decisions came out of it.
**Remediation is server-declared metadata, not SPA-derived.** Each check that has a fix knows where the
fix lives, so the *check* declares it. The domain `HealthCheckLink` grew from `(string Link)` to
`(string Target, HealthCheckLinkKind Kind)` with `Kind ∈ {ExternalDoc, AppRoute}` and two factories
(`HealthCheckLink.ExternalDoc(url)` / `HealthCheckLink.AppRoute("/app/...")`). Only the 4 checks that
built links and the API mapper touched `.Link`, so the widening was local. The SPA then *acts* on the
kind: `AppRoute` → client-side `navigateToPath(target)` button; `ExternalDoc` → new-tab anchor. The
human label is derived SPA-side from the route (a small lookup + prettified fallback) rather than sent
over the wire — keeping the DTO minimal.
**The DTO evolved additively (`/api/v1` is frozen-additive, #286).** `HealthCheckResponseModel` kept
its existing `Detail` and gained `Brief` (← the domain `BriefMessage` the old mapper silently dropped)
and `Remediation { Kind, Target }` (a nested `HealthCheckRemediationResponseModel`). The old flat
`string? Link` is **kept and still populated** (mirrors `Remediation.Target`) but documented deprecated —
we don't remove a frozen field, and existing consumers keep working. `Remediation.Kind` is a plain
string ("ExternalDoc"/"AppRoute") mapped in the Application `Mapper` exactly like `Status`
("pass"/"fail"/…), not a wire enum — matching the established pattern for that DTO.
**Three defects the audit surfaced, fixed here.** (1) The Application `Mapper.GetStatus` threw
`ArgumentOutOfRangeException` on `NotApplicable`; the handler filters `NotApplicable` before mapping so
it was latent, but the mapper is now **total** (defense-in-depth — a future caller that skips the filter
can't 500 the endpoint). `InternalsVisibleTo("ErsatzTV.Tests")` was added to the Application assembly
(mirroring Core's precedent) to unit-test that totality directly. (2) Two checks linked to **stale
Blazor routes** (`media/trash`, `search?query=…`) — repointed to the SPA `/app/trash` and
`/app/search?query=…` as `AppRoute`s. (3) A dead `Open Classic UI` → `/system/health` link lingered in
`SettingsScreen` (a #91b leftover that just 302'd to `/app`); removed (see `blazor-route-parity.md`
Section 4 correction).
**Actionable checks that had no link gained an `AppRoute`** (metadata → `/app/libraries`, empty
schedules → `/app/schedules`, HW-accel / VAAPI → `/app/ffmpeg-profiles`, FFmpeg reports → `/app/settings`).
Pure-noise / no-clean-action checks (UnifiedDocker, MacOsConfigFolder, FFmpegCapabilities, the Info-tier
nags) were left untouched — semantic-tier changes (e.g. adding a Pass path, demoting a nag) were
deliberately **not** bundled into a remediation-UX PR.
**Deferred (own issue): a TTL cache for `PerformHealthChecks`** (#108 — every `GET /api/v1/health`
re-runs all 14 checks, 4 shelling out to ffmpeg, and the existing summary cache is write-only dead
code). Orthogonal to the UX; filed separately so a SPA-polled health panel gets a cache before it
polls.
+1 -1
View File
@@ -4,7 +4,7 @@ import type { components } from './generated/v1';
export type DashboardChannel = components['schemas']['ChannelResponseModel'];
export type DashboardChannelState = components['schemas']['ChannelStateResponseModel'];
type DashboardHealthCheck = components['schemas']['HealthCheckResponseModel'];
export type DashboardHealthCheck = components['schemas']['HealthCheckResponseModel'];
type DashboardMediaSource = components['schemas']['MediaSourceResponseModel'];
type DashboardPlayouts = components['schemas']['PagedPlayoutsResponseModel'];
export type DashboardVersion = components['schemas']['CombinedVersion'];
+6
View File
@@ -749,12 +749,18 @@ export interface components {
"HdhrSettingsResponseModel": {
"tunerCount": number;
"uuid": string;
};
"HealthCheckRemediationResponseModel": {
"kind": string;
"target": string;
};
"HealthCheckResponseModel": {
"title": string;
"status": string;
"detail": string;
"brief": null | string;
"link": null | string;
"remediation": null | components["schemas"]["HealthCheckRemediationResponseModel"];
};
"HlsSessionModel": {
"channelNumber": null | string;
+35
View File
@@ -145,6 +145,41 @@ describe('DashboardScreen', () => {
expect(await screen.findByText('No on-air channels reported')).toBeInTheDocument();
});
it('renders guided remediation: in-app route navigates, external doc opens in a new tab (#164)', async () => {
mockDashboardApi({
health: [
{
detail: 'There are 3 items that do not exist on disk',
brief: 'There are 3 items that do not exist on disk',
link: '/app/trash',
remediation: { kind: 'AppRoute', target: '/app/trash' },
status: 'warn',
title: 'File Not Found'
},
{
detail: 'Downgrade detected; THIS IS NOT SUPPORTED',
brief: 'Downgrade detected',
link: 'https://ersatztv.org/docs/installation/#downgrading',
remediation: { kind: 'ExternalDoc', target: 'https://ersatztv.org/docs/installation/#downgrading' },
status: 'fail',
title: 'ErsatzTV Downgrade'
}
]
});
renderDashboard();
// external-doc remediation is an anchor opening in a new tab
const docLink = await screen.findByRole('link', { name: /View docs/ });
expect(docLink).toHaveAttribute('href', 'https://ersatztv.org/docs/installation/#downgrading');
expect(docLink).toHaveAttribute('target', '_blank');
// app-route remediation is a button that performs client-side navigation
const routeButton = screen.getByRole('button', { name: /Open Trash/ });
fireEvent.click(routeButton);
expect(window.location.pathname).toBe('/app/trash');
});
it('shows failing health checks with error styling, distinct from neutral info checks', async () => {
mockDashboardApi({
health: [
+60 -1
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react';
import {
ArrowRight,
Check,
ExternalLink,
Info,
Library,
ListVideo,
@@ -23,8 +25,10 @@ import {
useDashboardQuery,
type DashboardChannel,
type DashboardChannelState,
type DashboardHealthCheck,
type DashboardHealthQueryState
} from '../api';
import { navigateToPath } from '../routing';
type HealthStatus = 'error' | 'idle' | 'live' | 'ok' | 'warn';
@@ -179,6 +183,58 @@ function DashboardErrorState({ error }: { error: string }) {
);
}
const APP_ROUTE_LABELS: Record<string, string> = {
'/app/trash': 'Open Trash',
'/app/search': 'Open Search',
'/app/libraries': 'Open Libraries',
'/app/schedules': 'Open Schedules',
'/app/ffmpeg-profiles': 'Open FFmpeg Profiles',
'/app/settings': 'Open Settings'
};
function remediationLabel(remediation: NonNullable<DashboardHealthCheck['remediation']>): string {
if (remediation.kind === 'ExternalDoc') {
return 'View docs';
}
const path = remediation.target.split('?')[0].replace(/\/+$/, '');
return APP_ROUTE_LABELS[path] ?? 'Open';
}
// Renders the "what to do about it" action for a health check, driven by the server's
// remediation metadata: an in-app route (client-side navigation) or an external doc (new tab).
function HealthRemediation({ remediation }: { remediation: DashboardHealthCheck['remediation'] }) {
if (!remediation) {
return null;
}
const label = remediationLabel(remediation);
if (remediation.kind === 'ExternalDoc') {
return (
<a
className="ctv-button ctv-button-secondary ctv-button-sm ctv-health-action"
href={remediation.target}
target="_blank"
rel="noreferrer"
>
<span>{label}</span>
<ExternalLink aria-hidden="true" size={13} />
</a>
);
}
return (
<Button
endIcon={<ArrowRight aria-hidden="true" size={13} />}
onClick={() => navigateToPath(remediation.target)}
size="sm"
variant="secondary"
>
{label}
</Button>
);
}
function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }) {
return (
<Card
@@ -234,7 +290,10 @@ function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }
<div className="ctv-health-row" key={check.title}>
<span className={`ctv-health-icon ctv-health-icon-${rowStatus}`}>{healthIcon(check.status)}</span>
<strong>{check.title}</strong>
<span>{check.detail}</span>
<span className="ctv-health-detail">
<span title={check.detail}>{check.detail}</span>
<HealthRemediation remediation={check.remediation} />
</span>
<StatusDot status={rowStatus} />
</div>
);
+7 -3
View File
@@ -175,13 +175,17 @@ describe('Settings screen (#93)', () => {
expect(await screen.findByText('Local')).toBeInTheDocument();
});
it('shows a Classic UI link to the legacy Blazor app in the System pane (#147)', async () => {
it('no longer shows the dead Classic UI link (#147 removed by #164; Blazor gone since #91b)', async () => {
// The legacy Blazor UI was removed in #91b and /system/health now 302s to /app, so the
// old "Open Classic UI" escape hatch was a dead link; blocks/decos/templates/playouts all
// live in the SPA now. The row was removed rather than repointed.
mockSettingsApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
const link = await screen.findByRole('link', { name: /Open Classic UI/ });
expect(link).toHaveAttribute('href', '/system/health');
// the About card's "Open Dashboard" button proves the pane rendered
expect(await screen.findByRole('button', { name: /Open Dashboard/ })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Open Classic UI/ })).not.toBeInTheDocument();
});
it('renders and stays editable when media sources (tier-2 reference data) fail to load', async () => {
-6
View File
@@ -900,12 +900,6 @@ function SystemPane({
Open Dashboard
</Button>
</Row>
<Row control={220} help="Blocks/decos/templates and playout editors still live here." label="Classic UI">
<a className="ctv-button ctv-button-secondary ctv-button-sm" href="/system/health">
<span>Open Classic UI</span>
<ExternalLink aria-hidden="true" size={13} />
</a>
</Row>
<Row control={220} help="Recent server log entries with level and free-text filtering." label="Logs">
<Button endIcon={<ArrowRight aria-hidden="true" size={13} />} onClick={() => navigateToPath('/app/logs')} size="sm" variant="secondary">
Open Logs
+23
View File
@@ -552,6 +552,29 @@ body {
border-top: 0;
}
.ctv-health-detail {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4, 8px);
min-width: 0;
}
/* the detail text lives in this inner span now that the cell is a flex wrapper; the
.ctv-health-row > span:nth-child(3) rule can no longer truncate it (flex box), so restore
the ellipsis affordance here on the actual text element. */
.ctv-health-detail > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-health-action {
flex: none;
white-space: nowrap;
}
.ctv-health-icon {
width: 24px;
height: 24px;