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/ApiControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs index a0142cc13..4acfe3ca8 100644 --- a/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs +++ b/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs @@ -29,6 +29,7 @@ public class ApiControllerSecurityTests typeof(CollectionController), typeof(FFmpegProfileController), typeof(LibrariesController), + typeof(LogsController), typeof(MaintenanceController), typeof(PlayoutController), typeof(ResolutionController), @@ -37,7 +38,8 @@ public class ApiControllerSecurityTests typeof(ScriptedScheduleController), typeof(SessionController), typeof(SettingsController), - typeof(SmartCollectionController) + typeof(SmartCollectionController), + typeof(TroubleshootController) ]; foreach (Type controllerType in apiControllers) 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..b9f51a6c3 --- /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..dc8daee88 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); diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index ad7d52319..d637af549 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -3088,6 +3088,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": [ @@ -5857,6 +5916,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": [ @@ -9140,6 +9496,26 @@ } } }, + "LogEntryResponseModel": { + "required": [ + "timestamp", + "level", + "message" + ], + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "level": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, "LogEventLevel": { "enum": [ "Verbose", @@ -9484,6 +9860,25 @@ } } }, + "PagedLogEntriesResponseModel": { + "required": [ + "totalCount", + "page" + ], + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogEntryResponseModel" + } + } + } + }, "PagedPlayoutItemsResponseModel": { "required": [ "totalCount", @@ -10569,6 +10964,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", @@ -11812,6 +12246,9 @@ { "name": "Libraries" }, + { + "name": "Logs" + }, { "name": "Maintenance" }, @@ -11836,6 +12273,9 @@ { "name": "Smart Collections" }, + { + "name": "Troubleshooting" + }, { "name": "Version" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 8f2dc601e..fbce3ee0b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -41,6 +41,7 @@ import { Play, Radio, RefreshCw, + ScrollText, Search, Server, Settings, @@ -60,7 +61,9 @@ import { ChannelEditScreen } from './screens/ChannelEditScreen'; import { CollectionsScreen } from './screens/CollectionsScreen'; import { FFmpegProfilesScreen } from './screens/FFmpegProfilesScreen'; import { FillerPresetsScreen } from './screens/FillerPresetsScreen'; +import { LogsScreen } from './screens/LogsScreen'; import { SettingsScreen } from './screens/SettingsScreen'; +import { TroubleshootingScreen } from './screens/TroubleshootingScreen'; import { WatermarksScreen } from './screens/WatermarksScreen'; import { navigateToPath } from './routing'; import { @@ -140,7 +143,9 @@ type ScreenId = | 'libraries' | 'ffmpegProfiles' | 'watermarks' - | 'settings'; + | 'settings' + | 'logs' + | 'troubleshooting'; interface ScreenRoute { id: ScreenId; @@ -310,6 +315,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: