diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj
index 4625ba925..a2831298e 100644
--- a/ErsatzTV.Application/ErsatzTV.Application.csproj
+++ b/ErsatzTV.Application/ErsatzTV.Application.csproj
@@ -26,4 +26,10 @@
+
+
+ <_Parameter1>ErsatzTV.Tests
+
+
+
diff --git a/ErsatzTV.Application/Health/Mapper.cs b/ErsatzTV.Application/Health/Mapper.cs
index 5e4ca919b..2946a2938 100644
--- a/ErsatzTV.Application/Health/Mapper.cs
+++ b/ErsatzTV.Application/Health/Mapper.cs
@@ -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)
+ };
}
diff --git a/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs
index 39cf3ae39..98255bc13 100644
--- a/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs
+++ b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs
@@ -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);
diff --git a/ErsatzTV.Core/Health/HealthCheckLink.cs b/ErsatzTV.Core/Health/HealthCheckLink.cs
index 91ee2ff15..b1478af6b 100644
--- a/ErsatzTV.Core/Health/HealthCheckLink.cs
+++ b/ErsatzTV.Core/Health/HealthCheckLink.cs
@@ -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);
+}
diff --git a/ErsatzTV.Core/Health/HealthCheckLinkKind.cs b/ErsatzTV.Core/Health/HealthCheckLinkKind.cs
new file mode 100644
index 000000000..2bc96162f
--- /dev/null
+++ b/ErsatzTV.Core/Health/HealthCheckLinkKind.cs
@@ -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
+}
diff --git a/ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs
index 0a08031ac..47c14b9d2 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/DowngradeHealthCheck.cs
@@ -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();
diff --git a/ErsatzTV.Infrastructure/Health/Checks/EmptyScheduleHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/EmptyScheduleHealthCheck.cs
index 27cfee288..b384fc22c 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/EmptyScheduleHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/EmptyScheduleHealthCheck.cs
@@ -40,7 +40,8 @@ public class EmptyScheduleHealthCheck(IDbContextFactory 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();
diff --git a/ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs
index 454024d8f..059df6f34 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/EpisodeMetadataHealthCheck.cs
@@ -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();
diff --git a/ErsatzTV.Infrastructure/Health/Checks/FFmpegReportsHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/FFmpegReportsHealthCheck.cs
index 3bf3fc0df..f6eec2c1a 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/FFmpegReportsHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/FFmpegReportsHealthCheck.cs
@@ -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"));
}
}
diff --git a/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs
index f0f663f72..6ca115455 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs
@@ -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 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 maybeFFmpegPath =
await configElementRepository.GetConfigElement(ConfigElementKey.FFmpegPath, cancellationToken);
diff --git a/ErsatzTV.Infrastructure/Health/Checks/FileNotFoundHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/FileNotFoundHealthCheck.cs
index 8792c3496..96a0a84cc 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/FileNotFoundHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/FileNotFoundHealthCheck.cs
@@ -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();
diff --git a/ErsatzTV.Infrastructure/Health/Checks/HardwareAccelerationHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/HardwareAccelerationHealthCheck.cs
index 7fa6cc722..be8c5d710 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/HardwareAccelerationHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/HardwareAccelerationHealthCheck.cs
@@ -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;
diff --git a/ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs
index 010697643..8adc3538a 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/MovieMetadataHealthCheck.cs
@@ -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();
diff --git a/ErsatzTV.Infrastructure/Health/Checks/UnavailableHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/UnavailableHealthCheck.cs
index e23ea4b78..f266a03ef 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/UnavailableHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/UnavailableHealthCheck.cs
@@ -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();
diff --git a/ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs
index 178a449fe..ec8665122 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/VaapiDriverHealthCheck.cs
@@ -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"));
}
}
}
diff --git a/ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs b/ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs
index 2709d26c5..3dc62005c 100644
--- a/ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs
+++ b/ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs
@@ -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();
diff --git a/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs
index d0e9a0ff4..e3176ceab 100644
--- a/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs
+++ b/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs
@@ -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
{
@@ -69,7 +69,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
HealthCheckStatus.Warning,
"detail message",
"brief",
- Option.Some(new HealthCheckLink("https://example.com/docs")))
+ Option.Some(HealthCheckLink.ExternalDoc("https://example.com/docs")))
};
_healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results);
@@ -77,12 +77,41 @@ public class GetAllHealthCheckResultsForApiHandlerTests
List 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
+ {
+ new(
+ "Routed Check",
+ HealthCheckStatus.Warning,
+ "detail message",
+ "brief",
+ Option.Some(HealthCheckLink.AppRoute("/app/trash")))
+ };
+
+ _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results);
+
+ List 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
{
@@ -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
+ {
+ new("Blank Brief", HealthCheckStatus.Pass, "detail", string.Empty, Option.None)
+ };
+
+ _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results);
+
+ List response =
+ await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
+
+ response[0].Brief.ShouldBeNull();
}
[Test]
diff --git a/ErsatzTV.Tests/Application/Health/HealthMapperTests.cs b/ErsatzTV.Tests/Application/Health/HealthMapperTests.cs
new file mode 100644
index 000000000..0093dbd4c
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Health/HealthMapperTests.cs
@@ -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.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.None);
+
+ Mapper.ProjectToResponseModel(result).Status.ShouldBe(expected);
+ }
+}
diff --git a/ErsatzTV.Tests/Controllers/HealthControllerTests.cs b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs
index 153f52c82..867a365aa 100644
--- a/ErsatzTV.Tests/Controllers/HealthControllerTests.cs
+++ b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs
@@ -40,8 +40,14 @@ public class HealthControllerTests
{
var expected = new List
{
- 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(), Arg.Any())
diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json
index 267c5d288..6f45f9716 100644
--- a/ErsatzTV/wwwroot/openapi/v1.json
+++ b/ErsatzTV/wwwroot/openapi/v1.json
@@ -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"
+ }
+ ]
}
}
},
diff --git a/docs/api-conventions.md b/docs/api-conventions.md
index 7d02931b9..d4e6efa70 100644
--- a/docs/api-conventions.md
+++ b/docs/api-conventions.md
@@ -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
diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md
index 451852907..6b5ecab24 100644
--- a/docs/blazor-route-parity.md
+++ b/docs/blazor-route-parity.md
@@ -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 `Open
+Classic UI` 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 |
|---|---|---|
diff --git a/docs/decisions.md b/docs/decisions.md
index 1ece387a1..7f68a484d 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -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.
diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts
index 4c3f582ea..be87d87cb 100644
--- a/web/src/api/dashboard.ts
+++ b/web/src/api/dashboard.ts
@@ -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'];
diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts
index 9fd3bffd6..2bb390a5a 100644
--- a/web/src/api/generated/v1.d.ts
+++ b/web/src/api/generated/v1.d.ts
@@ -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;
diff --git a/web/src/screens/DashboardScreen.test.tsx b/web/src/screens/DashboardScreen.test.tsx
index dd470212a..bfc8f48f0 100644
--- a/web/src/screens/DashboardScreen.test.tsx
+++ b/web/src/screens/DashboardScreen.test.tsx
@@ -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: [
diff --git a/web/src/screens/DashboardScreen.tsx b/web/src/screens/DashboardScreen.tsx
index cbbd08628..e4f9823c1 100644
--- a/web/src/screens/DashboardScreen.tsx
+++ b/web/src/screens/DashboardScreen.tsx
@@ -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 = {
+ '/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): 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 (
+
+ {label}
+
+
+ );
+ }
+
+ return (
+ }
+ onClick={() => navigateToPath(remediation.target)}
+ size="sm"
+ variant="secondary"
+ >
+ {label}
+
+ );
+}
+
function HealthPanel({ healthState }: { healthState: DashboardHealthQueryState }) {
return (
{healthIcon(check.status)}{check.title}
- {check.detail}
+
+ {check.detail}
+
+
);
diff --git a/web/src/screens/SettingsScreen.test.tsx b/web/src/screens/SettingsScreen.test.tsx
index 416b7ca9b..f6357be63 100644
--- a/web/src/screens/SettingsScreen.test.tsx
+++ b/web/src/screens/SettingsScreen.test.tsx
@@ -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 () => {
diff --git a/web/src/screens/SettingsScreen.tsx b/web/src/screens/SettingsScreen.tsx
index 8854c0b38..a7ae2f8be 100644
--- a/web/src/screens/SettingsScreen.tsx
+++ b/web/src/screens/SettingsScreen.tsx
@@ -900,12 +900,6 @@ function SystemPane({
Open Dashboard
-
-
- Open Classic UI
-
-
-
} onClick={() => navigateToPath('/app/logs')} size="sm" variant="secondary">
Open Logs
diff --git a/web/src/shell.css b/web/src/shell.css
index 221358688..3d52bfdbf 100644
--- a/web/src/shell.css
+++ b/web/src/shell.css
@@ -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;