From dfa561a97e87b83f4caefda516e1a56f9752f03e Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 13:34:47 +0200 Subject: [PATCH 1/4] feat(api): logs and troubleshooting-info endpoints for SPA parity (#145, #158) Add GET /api/logs (thin wrapper over GetRecentLogEntries; paged, filtered, clamped pageSize like LibraryBrowseController) and GET /api/troubleshoot/info (wraps GetTroubleshootingInfo; General section serialized to the same JSON shape the legacy Blazor Troubleshooting page renders, plus per-platform capability dumps). Also adds [EndpointGroupName("general")] to the existing troubleshoot playback endpoints so they surface in the OpenAPI spec (#158 item 3). --- .../Api/Logs/LogEntryResponseModel.cs | 7 ++ .../Api/Logs/PagedLogEntriesResponseModel.cs | 6 ++ .../TroubleshootingInfoResponseModel.cs | 14 +++ .../Controllers/LogsControllerTests.cs | 89 +++++++++++++++++++ .../TroubleshootControllerTests.cs | 84 +++++++++++++++++ ErsatzTV/Controllers/Api/LogsController.cs | 39 ++++++++ .../Controllers/Api/TroubleshootController.cs | 57 ++++++++++++ 7 files changed, 296 insertions(+) create mode 100644 ErsatzTV.Core/Api/Logs/LogEntryResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Logs/PagedLogEntriesResponseModel.cs create mode 100644 ErsatzTV.Core/Api/Troubleshooting/TroubleshootingInfoResponseModel.cs create mode 100644 ErsatzTV.Tests/Controllers/LogsControllerTests.cs create mode 100644 ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs create mode 100644 ErsatzTV/Controllers/Api/LogsController.cs diff --git a/ErsatzTV.Core/Api/Logs/LogEntryResponseModel.cs b/ErsatzTV.Core/Api/Logs/LogEntryResponseModel.cs new file mode 100644 index 000000000..d49ec46b1 --- /dev/null +++ b/ErsatzTV.Core/Api/Logs/LogEntryResponseModel.cs @@ -0,0 +1,7 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Logs; + +public record LogEntryResponseModel( + DateTimeOffset Timestamp, + string Level, + string Message); diff --git a/ErsatzTV.Core/Api/Logs/PagedLogEntriesResponseModel.cs b/ErsatzTV.Core/Api/Logs/PagedLogEntriesResponseModel.cs new file mode 100644 index 000000000..578940078 --- /dev/null +++ b/ErsatzTV.Core/Api/Logs/PagedLogEntriesResponseModel.cs @@ -0,0 +1,6 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Logs; + +public record PagedLogEntriesResponseModel( + int TotalCount, + List Page); diff --git a/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingInfoResponseModel.cs b/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingInfoResponseModel.cs new file mode 100644 index 000000000..2feb5fc45 --- /dev/null +++ b/ErsatzTV.Core/Api/Troubleshooting/TroubleshootingInfoResponseModel.cs @@ -0,0 +1,14 @@ +#nullable enable +namespace ErsatzTV.Core.Api.Troubleshooting; + +// GeneralJson mirrors the JSON blob rendered on the "General" tab of the legacy Blazor +// Troubleshooting page (version, environment, cpus, video controllers, health, ffmpeg settings, +// AviSynth flags, channels, ffmpeg profiles) - kept pre-serialized so the SPA can render/copy it +// verbatim without re-deriving the same shape. The remaining fields mirror the per-platform +// capability dump tabs (only the ones relevant to the current OS/GPU are non-empty). +public record TroubleshootingInfoResponseModel( + string GeneralJson, + string? NvidiaCapabilities, + string? QsvCapabilities, + string? VaapiCapabilities, + string? VideoToolboxCapabilities); diff --git a/ErsatzTV.Tests/Controllers/LogsControllerTests.cs b/ErsatzTV.Tests/Controllers/LogsControllerTests.cs new file mode 100644 index 000000000..467b82f30 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/LogsControllerTests.cs @@ -0,0 +1,89 @@ +using System.Reflection; +using ErsatzTV.Application.Logs; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Logs; +using MediatR; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Serilog.Events; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class LogsControllerTests +{ + private LogsController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new LogsController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route() + { + MethodInfo action = typeof(LogsController).GetMethod(nameof(LogsController.GetLogs)) + ?? throw new AssertionException("Missing action GetLogs"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain("GET"); + attribute.Template.ShouldBe("/api/logs"); + attribute.Name.ShouldBe("GetLogs"); + } + + [Test] + public async Task GetLogs_Should_Clamp_Paging_And_Send_Query() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(-1, 500, "boom", CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => + q.PageNum == 0 && + q.PageSize == 100 && + q.Filter == "boom"), + Arg.Any()); + } + + [Test] + public async Task GetLogs_Should_Map_Entries_To_Response_Model() + { + var entries = new List + { + new(DateTimeOffset.UnixEpoch, LogEventLevel.Warning, "uh oh"), + new(DateTimeOffset.UnixEpoch.AddMinutes(1), LogEventLevel.Information, "all good") + }; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(2, entries)); + + PagedLogEntriesResponseModel result = await _controller.GetLogs( + cancellationToken: CancellationToken.None); + + result.TotalCount.ShouldBe(2); + result.Page.ShouldBe( + [ + new LogEntryResponseModel(DateTimeOffset.UnixEpoch, "Warning", "uh oh"), + new LogEntryResponseModel(DateTimeOffset.UnixEpoch.AddMinutes(1), "Information", "all good") + ]); + } + + [Test] + public async Task GetLogs_Should_Return_Empty_Page_When_None_Exist() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + PagedLogEntriesResponseModel result = await _controller.GetLogs( + cancellationToken: CancellationToken.None); + + result.TotalCount.ShouldBe(0); + result.Page.ShouldBeEmpty(); + } +} diff --git a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs new file mode 100644 index 000000000..3eef07d1f --- /dev/null +++ b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs @@ -0,0 +1,84 @@ +using System.IO.Abstractions; +using System.Reflection; +using System.Text.Json; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Troubleshooting; +using ErsatzTV.Application.Troubleshooting.Queries; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Troubleshooting; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Troubleshooting; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class TroubleshootControllerTests +{ + private TroubleshootController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new TroubleshootController( + Channel.CreateUnbounded().Writer, + Substitute.For(), + Substitute.For(), + Substitute.For(), + _mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Info() + { + MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetInfo)) + ?? throw new AssertionException("Missing action GetInfo"); + + var attribute = action.GetCustomAttributes().Single(); + attribute.Template.ShouldBe("/api/troubleshoot/info"); + attribute.Name.ShouldBe("GetTroubleshootingInfo"); + } + + [Test] + public async Task GetInfo_Should_Serialize_General_Section_And_Carry_Platform_Capabilities() + { + var info = new TroubleshootingInfo( + "1.2.3", + new Dictionary { ["ETV_FOO"] = "bar" }, + [], + [], + [], + new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(), + [], + [], + [], + false, + false, + "nvidia output", + "qsv output", + "vaapi output", + "videotoolbox output"); + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(info); + + TroubleshootingInfoResponseModel result = await _controller.GetInfo(CancellationToken.None); + + result.NvidiaCapabilities.ShouldBe("nvidia output"); + result.QsvCapabilities.ShouldBe("qsv output"); + result.VaapiCapabilities.ShouldBe("vaapi output"); + result.VideoToolboxCapabilities.ShouldBe("videotoolbox output"); + + using JsonDocument document = JsonDocument.Parse(result.GeneralJson); + document.RootElement.GetProperty("Version").GetString().ShouldBe("1.2.3"); + document.RootElement.GetProperty("Environment").GetProperty("ETV_FOO").GetString().ShouldBe("bar"); + document.RootElement.GetProperty("AviSynth").GetProperty("Demuxer").GetBoolean().ShouldBeFalse(); + } +} diff --git a/ErsatzTV/Controllers/Api/LogsController.cs b/ErsatzTV/Controllers/Api/LogsController.cs new file mode 100644 index 000000000..46c6a1620 --- /dev/null +++ b/ErsatzTV/Controllers/Api/LogsController.cs @@ -0,0 +1,39 @@ +using ErsatzTV.Application.Logs; +using ErsatzTV.Core.Api.Logs; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class LogsController(IMediator mediator) : ControllerBase +{ + private const int MaxPageSize = 100; + + [HttpGet("/api/logs", Name = "GetLogs")] + [Tags("Logs")] + [EndpointSummary("Get recent log entries")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)] + public async Task GetLogs( + [FromQuery] int pageNum = 0, + [FromQuery] int pageSize = 100, + [FromQuery] string filter = "", + CancellationToken cancellationToken = default) + { + int clampedPageNum = Math.Max(0, pageNum); + int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); + + PagedLogEntriesViewModel result = await mediator.Send( + new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty), + cancellationToken); + + return new PagedLogEntriesResponseModel( + result.TotalCount, + result.Page.Map(ProjectToResponseModel).ToList()); + } + + private static LogEntryResponseModel ProjectToResponseModel(LogEntryViewModel viewModel) => + new(viewModel.Timestamp, viewModel.Level.ToString(), viewModel.Message); +} diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 8eabe6a0b..6e16b3719 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -1,15 +1,19 @@ using System.IO.Abstractions; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Core; +using ErsatzTV.Core.Api.Troubleshooting; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Troubleshooting; using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Serilog.Context; @@ -23,8 +27,55 @@ public class TroubleshootController( ITroubleshootingNotifier notifier, IMediator mediator) : ControllerBase { + private static readonly JsonSerializerOptions GeneralJsonOptions = new() + { + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + [HttpGet("/api/troubleshoot/info", Name = "GetTroubleshootingInfo")] + [Tags("Troubleshooting")] + [EndpointSummary("Get troubleshooting diagnostic info")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(TroubleshootingInfoResponseModel), StatusCodes.Status200OK)] + public async Task GetInfo(CancellationToken cancellationToken) + { + TroubleshootingInfo info = await mediator.Send(new GetTroubleshootingInfo(), cancellationToken); + + // mirrors the "General" tab JSON blob built by Pages/Troubleshooting/Troubleshooting.razor + string generalJson = JsonSerializer.Serialize( + new + { + info.Version, + Environment = info.Environment.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value), + info.Cpus, + info.VideoControllers, + info.Health, + info.FFmpegSettings, + AviSynth = new + { + Demuxer = info.AviSynthDemuxer, + Installed = info.AviSynthInstalled + }, + info.Channels, + info.FFmpegProfiles + }, + GeneralJsonOptions); + + return new TroubleshootingInfoResponseModel( + generalJson, + info.NvidiaCapabilities, + info.QsvCapabilities, + info.VaapiCapabilities, + info.VideoToolboxCapabilities); + } + [HttpHead("api/troubleshoot/playback.m3u8")] [HttpGet("api/troubleshoot/playback.m3u8")] + [Tags("Troubleshooting")] + [EndpointSummary("Start a troubleshooting playback session")] + [EndpointGroupName("general")] public async Task TroubleshootPlayback( [FromQuery] int mediaItem, @@ -159,6 +210,9 @@ public class TroubleshootController( [HttpHead("api/troubleshoot/playback/archive")] [HttpGet("api/troubleshoot/playback/archive")] + [Tags("Troubleshooting")] + [EndpointSummary("Download the last troubleshooting playback session archive")] + [EndpointGroupName("general")] public async Task TroubleshootPlaybackArchive(CancellationToken cancellationToken) { Option maybeArchivePath = await mediator.Send(new ArchiveTroubleshootingResults(), cancellationToken); @@ -182,6 +236,9 @@ public class TroubleshootController( [HttpHead("api/troubleshoot/playback/sample/{mediaItemId:int}")] [HttpGet("api/troubleshoot/playback/sample/{mediaItemId:int}")] + [Tags("Troubleshooting")] + [EndpointSummary("Download a media sample archive for troubleshooting")] + [EndpointGroupName("general")] public async Task TroubleshootPlaybackSample(int mediaItemId, CancellationToken cancellationToken) { Option maybeArchivePath = await mediator.Send(new ArchiveMediaSample(mediaItemId), cancellationToken); From bb34014a072ceb8bfd386ef5c8ff03c31b7448b0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 13:34:53 +0200 Subject: [PATCH 2/4] chore(api): regenerate OpenAPI spec + SPA types for logs/troubleshoot-info (#145) --- ErsatzTV/wwwroot/openapi/v1.json | 440 +++++++++++++++++++++++++++++++ web/src/api/generated/v1.d.ts | 16 ++ 2 files changed, 456 insertions(+) diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 2b7057dc0..dea50d788 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2663,6 +2663,65 @@ } } }, + "/api/logs": { + "get": { + "tags": [ + "Logs" + ], + "summary": "Get recent log entries", + "operationId": "GetLogs", + "parameters": [ + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "filter", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedLogEntriesResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedLogEntriesResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedLogEntriesResponseModel" + } + } + } + } + } + } + }, "/api/maintenance/gc": { "get": { "tags": [ @@ -5432,6 +5491,303 @@ } } }, + "/api/troubleshoot/info": { + "get": { + "tags": [ + "Troubleshooting" + ], + "summary": "Get troubleshooting diagnostic info", + "operationId": "GetTroubleshootingInfo", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/TroubleshootingInfoResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/TroubleshootingInfoResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/TroubleshootingInfoResponseModel" + } + } + } + } + } + } + }, + "/api/troubleshoot/playback.m3u8": { + "head": { + "tags": [ + "Troubleshooting" + ], + "summary": "Start a troubleshooting playback session", + "parameters": [ + { + "name": "mediaItem", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "channel", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "ffmpegProfile", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "streamingMode", + "in": "query", + "schema": { + "$ref": "#/components/schemas/StreamingMode" + } + }, + { + "name": "watermark", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "graphicsElement", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "streamSelector", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "subtitleId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "seekSeconds", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Troubleshooting" + ], + "summary": "Start a troubleshooting playback session", + "parameters": [ + { + "name": "mediaItem", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "channel", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "ffmpegProfile", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "streamingMode", + "in": "query", + "schema": { + "$ref": "#/components/schemas/StreamingMode" + } + }, + { + "name": "watermark", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "graphicsElement", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + }, + { + "name": "streamSelector", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "subtitleId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "seekSeconds", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/troubleshoot/playback/archive": { + "head": { + "tags": [ + "Troubleshooting" + ], + "summary": "Download the last troubleshooting playback session archive", + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Troubleshooting" + ], + "summary": "Download the last troubleshooting playback session archive", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/troubleshoot/playback/sample/{mediaItemId}": { + "head": { + "tags": [ + "Troubleshooting" + ], + "summary": "Download a media sample archive for troubleshooting", + "parameters": [ + { + "name": "mediaItemId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "get": { + "tags": [ + "Troubleshooting" + ], + "summary": "Download a media sample archive for troubleshooting", + "parameters": [ + { + "name": "mediaItemId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/version": { "get": { "tags": [ @@ -8027,6 +8383,26 @@ } } }, + "LogEntryResponseModel": { + "required": [ + "timestamp", + "level", + "message" + ], + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "level": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, "LogEventLevel": { "enum": [ "Verbose", @@ -8371,6 +8747,25 @@ } } }, + "PagedLogEntriesResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogEntryResponseModel" + } + } + } + }, "PagedPlayoutItemsResponseModel": { "required": [ "totalCount", @@ -9456,6 +9851,45 @@ ], "type": "string" }, + "TroubleshootingInfoResponseModel": { + "required": [ + "generalJson", + "nvidiaCapabilities", + "qsvCapabilities", + "vaapiCapabilities", + "videoToolboxCapabilities" + ], + "type": "object", + "properties": { + "generalJson": { + "type": "string" + }, + "nvidiaCapabilities": { + "type": [ + "null", + "string" + ] + }, + "qsvCapabilities": { + "type": [ + "null", + "string" + ] + }, + "vaapiCapabilities": { + "type": [ + "null", + "string" + ] + }, + "videoToolboxCapabilities": { + "type": [ + "null", + "string" + ] + } + } + }, "UiSettingsResponseModel": { "required": [ "isDarkMode", @@ -10403,6 +10837,9 @@ { "name": "Libraries" }, + { + "name": "Logs" + }, { "name": "Maintenance" }, @@ -10427,6 +10864,9 @@ { "name": "Smart Collections" }, + { + "name": "Troubleshooting" + }, { "name": "Version" }, diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index f3af9078b..24c1859e5 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -451,6 +451,11 @@ export interface components { "LibraryScanStatusResponseModel": { "libraryId": number; "percent": number; + }; + "LogEntryResponseModel": { + "timestamp": string; + "level": string; + "message": string; }; "LogEventLevel": "Verbose" | "Debug" | "Information" | "Warning" | "Error" | "Fatal"; "LoggingSettingsResponseModel": { @@ -518,6 +523,10 @@ export interface components { "PagedLibraryBrowseItemsResponseModel": { "totalCount": number; "page": Array; + }; + "PagedLogEntriesResponseModel": { + "totalCount": number; + "page": Array; }; "PagedPlayoutItemsResponseModel": { "totalCount": number; @@ -720,6 +729,13 @@ export interface components { "StartType": "Dynamic" | "Fixed"; "StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid"; "TailMode": "None" | "Offline" | "Slate" | "Filler"; + "TroubleshootingInfoResponseModel": { + "generalJson": string; + "nvidiaCapabilities": null | string; + "qsvCapabilities": null | string; + "vaapiCapabilities": null | string; + "videoToolboxCapabilities": null | string; + }; "UiSettingsResponseModel": { "isDarkMode": boolean; "language": string; From 5530db7089e3821201feec0d87f9fc16a4799bd6 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 13:42:01 +0200 Subject: [PATCH 3/4] feat(web): logs and troubleshooting screens for SPA parity (#145) Add typed API wrappers (api/logs.ts, api/troubleshoot.ts) and two new screens: LogsScreen (paged, level-badged, server-side filtered log table) and TroubleshootingScreen (General JSON viewer with copy, plus per-platform NVIDIA/QSV/VAAPI/VideoToolbox capability tabs when populated). Both are registered under a new "System" nav group in App.tsx alongside Settings. Settings' Classic UI help text and About card now point at the new screens instead of the Blazor logs/troubleshooting pages. --- web/src/App.tsx | 39 ++++- web/src/api/index.ts | 2 + web/src/api/logs.test.ts | 57 +++++++ web/src/api/logs.ts | 43 +++++ web/src/api/troubleshoot.test.ts | 43 +++++ web/src/api/troubleshoot.ts | 20 +++ web/src/screens/LogsScreen.tsx | 196 ++++++++++++++++++++++ web/src/screens/SettingsScreen.tsx | 12 +- web/src/screens/TroubleshootingScreen.tsx | 160 ++++++++++++++++++ web/src/shell.css | 14 ++ 10 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 web/src/api/logs.test.ts create mode 100644 web/src/api/logs.ts create mode 100644 web/src/api/troubleshoot.test.ts create mode 100644 web/src/api/troubleshoot.ts create mode 100644 web/src/screens/LogsScreen.tsx create mode 100644 web/src/screens/TroubleshootingScreen.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 29f9a517c..7cc61a3ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -41,6 +41,7 @@ import { Play, Radio, RefreshCw, + ScrollText, Search, Server, Settings, @@ -56,7 +57,9 @@ import chicoryMarkUrl from '../../design-system/assets/chicory-mark.svg'; import { ChannelBuilderScreen } from './builder/ChannelBuilder'; import { ChannelEditScreen } from './screens/ChannelEditScreen'; import { CollectionsScreen } from './screens/CollectionsScreen'; +import { LogsScreen } from './screens/LogsScreen'; import { SettingsScreen } from './screens/SettingsScreen'; +import { TroubleshootingScreen } from './screens/TroubleshootingScreen'; import { navigateToPath } from './routing'; import { Badge, @@ -132,7 +135,9 @@ type ScreenId = | 'playouts' | 'collections' | 'libraries' - | 'settings'; + | 'settings' + | 'logs' + | 'troubleshooting'; interface ScreenRoute { id: ScreenId; @@ -266,6 +271,28 @@ const routes: ScreenRoute[] = [ primaryAction: 'Save Changes', placeholder: 'Settings workspace', allowSubPaths: true + }, + { + id: 'logs', + path: '/app/logs', + label: 'Logs', + title: 'Logs', + kicker: 'System', + description: 'Recent server log entries with level and free-text filtering.', + icon: