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
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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>())
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'];
|
||||
|
||||
Vendored
+6
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user