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 ( + + ); +} + 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 - -