Merge remote-tracking branch 'origin/fix/213-logs-trash' into integrate/review-gates

# Conflicts:
#	docs/blazor-route-parity.md
This commit is contained in:
2026-07-11 01:49:50 +02:00
14 changed files with 541 additions and 38 deletions
@@ -42,7 +42,7 @@ public class LogsControllerTests
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>()) _mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, [])); .Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(-1, 500, "boom", CancellationToken.None); await _controller.GetLogs(-1, 500, "boom", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send( await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q => Arg.Is<GetRecentLogEntries>(q =>
@@ -52,6 +52,74 @@ public class LogsControllerTests
Arg.Any<CancellationToken>()); Arg.Any<CancellationToken>());
} }
[Test]
public async Task GetLogs_Should_Default_To_Timestamp_Descending()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q =>
q.SortDescending == true &&
SelectsTimestamp(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Sort_By_Level_Ascending_When_Requested()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortField: "level", sortDirection: "asc", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q =>
q.SortDescending == false &&
SelectsLevel(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Reject_Unknown_Sort_Field_And_Fall_Back_To_Timestamp()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortField: "message; DROP TABLE", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q => SelectsTimestamp(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Fall_Back_To_Descending_For_Unknown_Direction()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortDirection: "sideways", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q => q.SortDescending == true),
Arg.Any<CancellationToken>());
}
private static bool SelectsTimestamp(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
{
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
return Equals(expr.Compile()(sample), sample.Timestamp);
}
private static bool SelectsLevel(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
{
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
return Equals(expr.Compile()(sample), sample.Level);
}
[Test] [Test]
public async Task GetLogs_Should_Map_Entries_To_Response_Model() public async Task GetLogs_Should_Map_Entries_To_Response_Model()
{ {
+28 -1
View File
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ErsatzTV.Application.Logs; using ErsatzTV.Application.Logs;
using ErsatzTV.Core.Api.Logs; using ErsatzTV.Core.Api.Logs;
using MediatR; using MediatR;
@@ -11,22 +12,48 @@ public class LogsController(IMediator mediator) : ControllerBase
{ {
private const int MaxPageSize = 100; private const int MaxPageSize = 100;
// Mirrors the sortable columns from the legacy Blazor Logs.razor (MudTableSortLabel on
// Timestamp/Level; Message was never sortable there either).
private static readonly System.Collections.Generic.HashSet<string> AllowedSortFields =
new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" };
[HttpGet("/api/logs", Name = "GetLogs")] [HttpGet("/api/logs", Name = "GetLogs")]
[Tags("Logs")] [Tags("Logs")]
[EndpointSummary("Get recent log entries")] [EndpointSummary("Get recent log entries")]
[EndpointDescription(
"sortField is validated against an allow-list (timestamp, level); an unrecognized value " +
"falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the " +
"pre-existing default, newest first).")]
[EndpointGroupName("general")] [EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLogEntriesResponseModel> GetLogs( public async Task<PagedLogEntriesResponseModel> GetLogs(
[FromQuery] int pageNum = 0, [FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100, [FromQuery] int pageSize = 100,
[FromQuery] string filter = "", [FromQuery] string filter = "",
[FromQuery] string sortField = "timestamp",
[FromQuery] string sortDirection = "desc",
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
int clampedPageNum = Math.Max(0, pageNum); int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize); int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
string normalizedSortField = AllowedSortFields.Contains(sortField ?? string.Empty)
? sortField!.ToLowerInvariant()
: "timestamp";
bool descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase);
Expression<Func<LogEntryViewModel, object>> sortExpression = normalizedSortField switch
{
"level" => le => le.Level,
_ => le => le.Timestamp
};
PagedLogEntriesViewModel result = await mediator.Send( PagedLogEntriesViewModel result = await mediator.Send(
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty), new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
{
SortExpression = sortExpression,
SortDescending = descending
},
cancellationToken); cancellationToken);
return new PagedLogEntriesResponseModel( return new PagedLogEntriesResponseModel(
+17
View File
@@ -5470,6 +5470,7 @@
"Logs" "Logs"
], ],
"summary": "Get recent log entries", "summary": "Get recent log entries",
"description": "sortField is validated against an allow-list (timestamp, level); an unrecognized value falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the pre-existing default, newest first).",
"operationId": "GetLogs", "operationId": "GetLogs",
"parameters": [ "parameters": [
{ {
@@ -5497,6 +5498,22 @@
"type": "string", "type": "string",
"default": "" "default": ""
} }
},
{
"name": "sortField",
"in": "query",
"schema": {
"type": "string",
"default": "timestamp"
}
},
{
"name": "sortDirection",
"in": "query",
"schema": {
"type": "string",
"default": "desc"
}
} }
], ],
"responses": { "responses": {
+6
View File
@@ -32,6 +32,12 @@ Exemplars:
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)` `pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client (`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
input for page math. input for page math.
- **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are
normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with
a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized
`sortDirection` falls back to the default direction. Copy this pattern (normalize, don't 422) for
any new sortable endpoint — it matches the pageNum/pageSize clamp precedent above and keeps a bad
query string from ever producing an error response for a read-only listing.
## 2. DTOs: where they live and their nullable context ## 2. DTOs: where they live and their nullable context
+13 -10
View File
@@ -60,10 +60,10 @@ redirect).
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — | > | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
> | Playout creation + alternate-schedules | PARITY-OK | — | > | Playout creation + alternate-schedules | PARITY-OK | — |
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — | > | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear added; 100/kind cap → decisions.md) | — | > | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — |
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — | > | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 | > | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 |
> | Channels-numbers / Logs | PARITY-OK / minors | #213 | > | Channels-numbers / Logs | PARITY-OK (logs sort + page-size persistence added, 2026-07-11) | — |
> | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — | > | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — |
> | Media browse/detail (read paths + image browser) | PARITY-OK | — | > | Media browse/detail (read paths + image browser) | PARITY-OK | — |
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — | > | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
@@ -181,14 +181,11 @@ REST endpoints exist and are unused; only item delete/reorder and the schedule s
### Remaining mutation-depth gaps inside Section 2 rows ### Remaining mutation-depth gaps inside Section 2 rows
Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor), Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor),
#213 (remaining nits: logs sort + page-size persistence, block-history page-size/gating, #213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
blocks/templates list filter — all read-only conveniences). persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as
2026-07-11 (branch `fix/213-spa-nits`): block-history page-size persistence (`localStorage`, `ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on
key `ctv-block-history-page-size`, same `ctv-` namespace as `ctv-theme`) + gating the History the Blocks and Templates lists (`fix/213-spa-nits`); logs column sorting + page-size persistence
action on `block.id >= 0` (`BlockPlayoutTroubleshootingScreen.tsx`), and client-side name/group and trash per-kind "see all" paging (`fix/213-logs-trash`).
search/filter boxes on the Blocks and Templates list screens, all landed. Still open under #213:
logs sort + page-size persistence, trash paging (tracked on a separate concurrent branch,
`fix/213-logs-trash`).
CLOSED 2026-07-09: **#210** (playout delete/reset/erase/scheduling-context + preview calendar) CLOSED 2026-07-09: **#210** (playout delete/reset/erase/scheduling-context + preview calendar)
and **#211** (collection custom order + all-kind add picker); the block/watermark copy, trash and **#211** (collection custom order + all-kind add picker); the block/watermark copy, trash
select-all, and Trakt-note items of #213 landed in the same PR. select-all, and Trakt-note items of #213 landed in the same PR.
@@ -214,6 +211,12 @@ id-keyed `PlayoutController` mutation + `ChannelController.ResetPlayout` returns
DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on
Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`. Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`.
2026-07-11 (#213, remaining scope): logs sort (`GET /api/logs` `sortField`/`sortDirection`,
clickable column headers) and page-size persistence (client-local `localStorage`, not a server
`ConfigElement`) landed; trash "see all" now pages past the 100/kind cap via
`GET /api/library/browse` (no new API surface — see `docs/decisions.md`). The sibling branch landed the rest
(block-history page-size/gating, blocks/templates list filters) — #213 fully closed.
## Section 4 — Blazor home / escape hatch ## Section 4 — Blazor home / escape hatch
Not gated on an issue — kept separate from Section 3 because it isn't blocked on anything, just the Not gated on an issue — kept separate from Section 3 because it isn't blocked on anything, just the
+36
View File
@@ -181,6 +181,42 @@ that per-kind cap); this mirrors the legacy Blazor Trash page, which had the sam
behavior and cap. True paging is deferred until the search API grows a page param — not attempted behavior and cap. True paging is deferred until the search API grows a page param — not attempted
here, since it would mean adding a paging contract server-side, out of scope for this pass. here, since it would mean adding a paging contract server-side, out of scope for this pass.
**Superseded 2026-07-11 (#213)**: the cap is lifted via a per-kind "See all N …" button, without
adding any new API surface. `GET /api/library/browse` (`LibraryBrowseController` /
`GetLibraryBrowseItems`) already accepts `mediaType` + `pageNum` + `pageSize` and runs the same
underlying query as `GetSearchResults` (which itself fans out to `GetLibraryBrowseItems` per kind,
just always at `pageNum=0`) — so `TrashScreen.tsx` pages `pageNum=1, 2, …` through
`/api/library/browse?query=state:FileNotFound&mediaType={kind}&pageSize=100` for a kind once the
user asks to see past the first 100, and appends the results client-side. The 100/kind **first
page** still comes from `/api/search` (unchanged, cheapest for the common case where a kind has
few matches); only kinds that exceed the cap ever issue the follow-up `/api/library/browse` calls.
## 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement
The legacy Blazor Logs page persisted the user's chosen rows-per-page via
`ConfigElementKey.LogsPageSize` (`SaveConfigElementByKey`/`GetConfigElementByKey`), a
per-server-instance setting stored in the DB. `LogsScreen.tsx` instead persists it to
`window.localStorage` under `ctv-logs-page-size` (same wrapped-`Storage` pattern as
`designSystem.ts`'s theme preference: try/catch getter, validated against the known option set,
falls back to a default) and restores it on mount. Deliberate deviation: this is a per-browser UI
preference, not server/business state — no other client should see or be affected by it, so there
is no reason to round-trip it through the API and grow a new `/api/*` surface (or reuse the
generic config-element endpoints) just to store a page-size number. Follows the existing SPA
localStorage convention (`designSystem.ts` theme, `auth.ts` token) rather than introducing a new
persistence mechanism.
## 2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`
Parity for `Logs.razor`'s `MudTableSortLabel` columns (Timestamp, Level — Message was never
sortable in Blazor either). `LogsController.GetLogs` adds `sortField` (`timestamp` | `level`,
default `timestamp`) and `sortDirection` (`asc` | `desc`, default `desc`) query params, normalized
server-side the same way `pageNum`/`pageSize` are clamped rather than rejected with a 422: an
unrecognized `sortField` silently falls back to `timestamp`, an unrecognized `sortDirection` falls
back to `desc` — the pre-existing default behavior (newest-first) is unreachable to break via a bad
query string. `LogsScreen.tsx` renders the two sortable headers as buttons with a chevron
indicating the active field/direction; clicking the active column toggles direction, clicking the
other column switches to it ascending.
## 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode ## 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode
Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control
+14
View File
@@ -159,6 +159,20 @@ page's action row) and the `AddToCollectionDialog` / `AddToPlaylistDialog` / `Ad
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
and the accepted deviations from Blazor). and the accepted deviations from Blazor).
## 5d. Client-local preferences: `localStorage`, namespaced `ctv-*` keys
Per-browser UI preferences (theme, an auth token, a screen's remembered page size) live in
`window.localStorage` under a namespaced `ctv-` key, **not** a round-trip through the API — the
established pattern is `designSystem.ts`'s `getStoredDesignSystemTheme`/`applyDesignSystemTheme`
(`ctv-theme`): a small `getStorage()` helper that returns `window.localStorage` wrapped in a
try/catch (so a disabled/unavailable storage API degrades to the default instead of throwing), a
getter that validates the stored value against the known option set before trusting it, and a
setter that writes straight through. `LogsScreen.tsx`'s page-size persistence (`ctv-logs-page-size`,
#213) follows the same shape. Reserve this for state that's genuinely local to the browser/user
session — if a preference needs to be shared across devices or is really server/business state
(e.g. Blazor's `ConfigElement`-backed settings), it belongs behind an API endpoint instead; see
`docs/decisions.md` 2026-07-11 for the specific reasoning on logs page-size.
## 6. Tests ## 6. Tests
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file. - **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
+14
View File
@@ -44,6 +44,20 @@ describe('getLogs', () => {
expect(url).toBe('/api/logs?filter=boom&pageNum=2&pageSize=50'); expect(url).toBe('/api/logs?filter=boom&pageNum=2&pageSize=50');
}); });
it('builds the query string from sortField and sortDirection', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(samplePage), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getLogs({ sortDirection: 'asc', sortField: 'level' });
const [url] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/logs?sortField=level&sortDirection=asc');
});
it('rejects with the ApiError status on failure', async () => { it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue( vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), { new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
+13
View File
@@ -4,10 +4,15 @@ import type { components } from './generated/v1';
export type LogEntry = components['schemas']['LogEntryResponseModel']; export type LogEntry = components['schemas']['LogEntryResponseModel'];
export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel']; export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel'];
export type LogsSortField = 'timestamp' | 'level';
export type LogsSortDirection = 'asc' | 'desc';
export interface GetLogsParams { export interface GetLogsParams {
filter?: string; filter?: string;
pageNum?: number; pageNum?: number;
pageSize?: number; pageSize?: number;
sortDirection?: LogsSortDirection;
sortField?: LogsSortField;
} }
export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> { export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
@@ -25,6 +30,14 @@ export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
searchParams.set('pageSize', String(params.pageSize)); searchParams.set('pageSize', String(params.pageSize));
} }
if (params.sortField) {
searchParams.set('sortField', params.sortField);
}
if (params.sortDirection) {
searchParams.set('sortDirection', params.sortDirection);
}
const queryString = searchParams.toString(); const queryString = searchParams.toString();
return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`); return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`);
+94
View File
@@ -0,0 +1,94 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LogsScreen } from './LogsScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const samplePage = {
totalCount: 2,
page: [
{ timestamp: '2026-07-07T00:00:00Z', level: 'Warning', message: 'uh oh' },
{ timestamp: '2026-07-07T00:01:00Z', level: 'Information', message: 'all good' }
]
};
function mockApi() {
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
if (url.startsWith('/api/logs')) {
return Promise.resolve(jsonResponse(samplePage));
}
return Promise.resolve(new Response(null, { status: 204 }));
});
}
function lastLogsUrl(fetchMock: ReturnType<typeof mockApi>): string {
const call = [...fetchMock.mock.calls].reverse().find(([u]) => u.toString().startsWith('/api/logs'));
return call ? call[0].toString() : '';
}
describe('LogsScreen', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeEach(() => {
window.localStorage.clear();
});
it('loads with the default timestamp/desc sort', async () => {
const fetchMock = mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
expect(lastLogsUrl(fetchMock)).toContain('sortField=timestamp');
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=desc');
});
it('toggles sort direction when clicking the active column', async () => {
const fetchMock = mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
fireEvent.click(screen.getByRole('button', { name: /Timestamp/ }));
await screen.findByText('uh oh');
expect(lastLogsUrl(fetchMock)).toContain('sortField=timestamp');
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=asc');
});
it('switches sort field to ascending when clicking a new column', async () => {
const fetchMock = mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
fireEvent.click(screen.getByRole('button', { name: /Level/ }));
await screen.findByText('uh oh');
expect(lastLogsUrl(fetchMock)).toContain('sortField=level');
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=asc');
});
it('persists page size to localStorage and restores it on mount', async () => {
mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
fireEvent.change(screen.getByDisplayValue('50'), { target: { value: '100' } });
expect(await screen.findByDisplayValue('100')).toBeInTheDocument();
expect(window.localStorage.getItem('ctv-logs-page-size')).toBe('100');
cleanup();
const fetchMock = mockApi();
render(<LogsScreen />);
await screen.findByText('uh oh');
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
expect(lastLogsUrl(fetchMock)).toContain('pageSize=100');
});
});
+89 -11
View File
@@ -1,9 +1,23 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react'; import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Info,
RefreshCw,
Search,
TriangleAlert
} from 'lucide-react';
import { Badge, Button, Card, IconButton, Input, Select, Spinner } from '../components'; import { Badge, Button, Card, IconButton, Input, Select, Spinner } from '../components';
import { getLogs, messageFromLogsError, type LogEntry } from '../api'; import { getLogs, messageFromLogsError, type LogEntry, type LogsSortDirection, type LogsSortField } from '../api';
const PAGE_SIZE_OPTIONS = ['25', '50', '100']; const PAGE_SIZE_OPTIONS = ['25', '50', '100'];
const DEFAULT_PAGE_SIZE = 50;
// Client-local UI preference (not a Blazor-style server ConfigElement) — see docs/decisions.md
// 2026-07-11 "Logs page-size is a client-local preference".
const LOGS_PAGE_SIZE_STORAGE_KEY = 'ctv-logs-page-size';
const LEVEL_TONE: Record<string, 'neutral' | 'accent' | 'ok' | 'warn' | 'error'> = { const LEVEL_TONE: Record<string, 'neutral' | 'accent' | 'ok' | 'warn' | 'error'> = {
Debug: 'neutral', Debug: 'neutral',
@@ -24,11 +38,44 @@ function formatTimestamp(value: string): string {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
} }
function getStorage(): Storage | undefined {
if (typeof window === 'undefined') {
return undefined;
}
try {
return window.localStorage;
} catch {
return undefined;
}
}
function getStoredPageSize(): number {
const stored = getStorage()?.getItem(LOGS_PAGE_SIZE_STORAGE_KEY) ?? null;
return stored != null && PAGE_SIZE_OPTIONS.includes(stored) ? Number(stored) : DEFAULT_PAGE_SIZE;
}
function storePageSize(pageSize: number): void {
getStorage()?.setItem(LOGS_PAGE_SIZE_STORAGE_KEY, String(pageSize));
}
interface SortableColumn {
field: LogsSortField;
label: string;
}
const COLUMNS: SortableColumn[] = [
{ field: 'timestamp', label: 'Timestamp' },
{ field: 'level', label: 'Level' }
];
export function LogsScreen() { export function LogsScreen() {
const [filterInput, setFilterInput] = useState(''); const [filterInput, setFilterInput] = useState('');
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [pageNum, setPageNum] = useState(0); const [pageNum, setPageNum] = useState(0);
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(() => getStoredPageSize());
const [sortField, setSortField] = useState<LogsSortField>('timestamp');
const [sortDirection, setSortDirection] = useState<LogsSortDirection>('desc');
const [state, setState] = useState<LogsState>({ entries: [], error: null, status: 'loading', totalCount: 0 }); const [state, setState] = useState<LogsState>({ entries: [], error: null, status: 'loading', totalCount: 0 });
const activeRef = useRef(true); const activeRef = useRef(true);
const seqRef = useRef(0); const seqRef = useRef(0);
@@ -56,7 +103,7 @@ export function LogsScreen() {
// a loading spinner on every filter keystroke or page change. // a loading spinner on every filter keystroke or page change.
const load = useCallback(() => { const load = useCallback(() => {
const id = ++seqRef.current; const id = ++seqRef.current;
getLogs({ filter, pageNum, pageSize }) getLogs({ filter, pageNum, pageSize, sortDirection, sortField })
.then((paged) => { .then((paged) => {
if (activeRef.current && id === seqRef.current) { if (activeRef.current && id === seqRef.current) {
setState({ entries: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 }); setState({ entries: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
@@ -67,7 +114,7 @@ export function LogsScreen() {
setState({ entries: [], error: messageFromLogsError(error), status: 'error', totalCount: 0 }); setState({ entries: [], error: messageFromLogsError(error), status: 'error', totalCount: 0 });
} }
}); });
}, [filter, pageNum, pageSize]); }, [filter, pageNum, pageSize, sortDirection, sortField]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -78,6 +125,22 @@ export function LogsScreen() {
load(); load();
}; };
const changePageSize = (nextPageSize: number) => {
setPageNum(0);
setPageSize(nextPageSize);
storePageSize(nextPageSize);
};
const toggleSort = (field: LogsSortField) => {
setPageNum(0);
if (field === sortField) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / pageSize)) : 1; const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / pageSize)) : 1;
return ( return (
@@ -91,10 +154,7 @@ export function LogsScreen() {
/> />
<span className="ctv-channels-spacer" /> <span className="ctv-channels-spacer" />
<Select <Select
onChange={(event) => { onChange={(event) => changePageSize(Number(event.target.value))}
setPageNum(0);
setPageSize(Number(event.target.value));
}}
options={PAGE_SIZE_OPTIONS} options={PAGE_SIZE_OPTIONS}
style={{ width: 96 }} style={{ width: 96 }}
value={String(pageSize)} value={String(pageSize)}
@@ -136,8 +196,26 @@ export function LogsScreen() {
<table aria-label="Recent log entries" className="ctv-channels-table"> <table aria-label="Recent log entries" className="ctv-channels-table">
<thead> <thead>
<tr> <tr>
<th>Timestamp</th> {COLUMNS.map((column) => {
<th>Level</th> const active = column.field === sortField;
return (
<th aria-sort={active ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} key={column.field}>
<button
className="ctv-logs-sort-button"
onClick={() => toggleSort(column.field)}
type="button"
>
{column.label}
{active &&
(sortDirection === 'asc' ? (
<ChevronUp aria-hidden="true" size={13} />
) : (
<ChevronDown aria-hidden="true" size={13} />
))}
</button>
</th>
);
})}
<th>Message</th> <th>Message</th>
</tr> </tr>
</thead> </thead>
+39
View File
@@ -44,6 +44,12 @@ const searchResults = {
songs: emptyGroup() songs: emptyGroup()
}; };
// Movies group has more matches than the /api/search first-page cap (100) to exercise "See all".
function searchResultsWithMoreMovies() {
const movieItems = Array.from({ length: 100 }, (_, i) => browseItem(i + 1, 'Movie', 200 + i));
return { ...searchResults, movies: { items: movieItems, totalCount: 130 }, shows: emptyGroup() };
}
interface MockOptions { interface MockOptions {
onRequest?: (url: string, method: string, body: unknown) => Response | null; onRequest?: (url: string, method: string, body: unknown) => Response | null;
} }
@@ -129,4 +135,37 @@ describe('TrashScreen', () => {
expect([...sentBody.ids].sort()).toEqual([101, 102, 103]); expect([...sentBody.ids].sort()).toEqual([101, 102, 103]);
}); });
}); });
it('pages past the 100/kind cap via "See all" using GET /api/library/browse', async () => {
const moreMovies = searchResultsWithMoreMovies();
const fetchMock = mockApi({
onRequest: (url, method) => {
if (url.startsWith('/api/search') && method === 'GET') {
return jsonResponse(moreMovies);
}
if (url.startsWith('/api/library/browse') && method === 'GET') {
expect(url).toContain('mediaType=Movie');
expect(url).toContain('pageNum=1');
expect(url).toContain('query=state%3AFileNotFound');
const extraMovies = Array.from({ length: 30 }, (_, i) => browseItem(i + 500, 'Movie', 900 + i));
return jsonResponse({ page: extraMovies, totalCount: 130 });
}
return null;
}
});
render(<TrashScreen />);
await screen.findByText('130 missing');
const seeAllButton = screen.getByRole('button', { name: 'See all 130 movies' });
fireEvent.click(seeAllButton);
await waitFor(() => {
expect(fetchMock.mock.calls.some(([u]) => u.toString().startsWith('/api/library/browse'))).toBe(true);
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: /See all/ })).not.toBeInTheDocument();
});
});
}); });
+83 -15
View File
@@ -4,15 +4,20 @@ import { Button, Card, ConfirmDialog, Spinner } from '../components';
import { import {
deleteMediaItems, deleteMediaItems,
emptyTrash, emptyTrash,
getLibraryBrowseItems,
getSearchResults, getSearchResults,
messageFromLibraryBrowseError,
messageFromSearchError, messageFromSearchError,
type LibraryBrowseItem, type LibraryBrowseItem,
type LibraryBrowseMediaType,
type SearchResults type SearchResults
} from '../api'; } from '../api';
import { MediaPosterCard } from '../media/MediaPosterCard'; import { MediaPosterCard } from '../media/MediaPosterCard';
// The search API's SearchController clamps pageSize to MaxPageSize=100 and has no page-number // The initial per-kind fetch uses /api/search (GetSearchResults), whose SearchController clamps
// param, so this is the largest single request possible — see docs/decisions.md ("Trash see all"). // pageSize to MaxPageSize=100 — see docs/decisions.md ("Trash see all"). "See all" beyond that
// first page pages through the same underlying data via GET /api/library/browse (mediaType +
// pageNum), which already supports paging — no new API surface was needed to lift the cap.
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page). // Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
const TRASH_QUERY = 'state:FileNotFound'; const TRASH_QUERY = 'state:FileNotFound';
@@ -20,21 +25,30 @@ const TRASH_QUERY = 'state:FileNotFound';
interface GroupDef { interface GroupDef {
key: keyof SearchResults; key: keyof SearchResults;
label: string; label: string;
mediaType: LibraryBrowseMediaType;
} }
const GROUPS: GroupDef[] = [ const GROUPS: GroupDef[] = [
{ key: 'movies', label: 'Movies' }, { key: 'movies', label: 'Movies', mediaType: 'Movie' },
{ key: 'shows', label: 'TV Shows' }, { key: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
{ key: 'seasons', label: 'Seasons' }, { key: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' },
{ key: 'episodes', label: 'Episodes' }, { key: 'episodes', label: 'Episodes', mediaType: 'Episode' },
{ key: 'artists', label: 'Artists' }, { key: 'artists', label: 'Artists', mediaType: 'Artist' },
{ key: 'musicVideos', label: 'Music Videos' }, { key: 'musicVideos', label: 'Music Videos', mediaType: 'MusicVideo' },
{ key: 'songs', label: 'Songs' }, { key: 'songs', label: 'Songs', mediaType: 'Song' },
{ key: 'otherVideos', label: 'Other Videos' }, { key: 'otherVideos', label: 'Other Videos', mediaType: 'OtherVideo' },
{ key: 'images', label: 'Images' }, { key: 'images', label: 'Images', mediaType: 'Image' },
{ key: 'remoteStreams', label: 'Remote Streams' } { key: 'remoteStreams', label: 'Remote Streams', mediaType: 'RemoteStream' }
]; ];
interface SeeAllState {
error: string | null;
items: LibraryBrowseItem[];
loading: boolean;
// Next page to request; page 0 was already loaded by the initial /api/search call.
nextPageNum: number;
}
type TrashState = type TrashState =
| { results: SearchResults; error: null; status: 'success' } | { results: SearchResults; error: null; status: 'success' }
| { results: null; error: string; status: 'error' } | { results: null; error: string; status: 'error' }
@@ -46,6 +60,7 @@ function mediaItemIdOf(item: LibraryBrowseItem): number | null {
export function TrashScreen() { export function TrashScreen() {
const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' }); const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' });
const [seeAll, setSeeAll] = useState<Partial<Record<keyof SearchResults, SeeAllState>>>({});
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null); const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -83,9 +98,45 @@ export function TrashScreen() {
const refresh = () => { const refresh = () => {
setState({ results: null, error: null, status: 'loading' }); setState({ results: null, error: null, status: 'loading' });
setSeeAll({});
load(); load();
}; };
const loadMore = (group: GroupDef) => {
const current = seeAll[group.key];
const nextPageNum = current?.nextPageNum ?? 1; // page 0 was already loaded by /api/search above
setSeeAll((prev) => ({
...prev,
[group.key]: { error: null, items: current?.items ?? [], loading: true, nextPageNum }
}));
getLibraryBrowseItems({ mediaType: group.mediaType, pageNum: nextPageNum, pageSize: PAGE_SIZE, query: TRASH_QUERY })
.then((page) => {
if (!activeRef.current) {
return;
}
setSeeAll((prev) => {
const existing = prev[group.key];
const items = [...(existing?.items ?? []), ...(page.page ?? [])];
return { ...prev, [group.key]: { error: null, items, loading: false, nextPageNum: nextPageNum + 1 } };
});
})
.catch((error: unknown) => {
if (!activeRef.current) {
return;
}
setSeeAll((prev) => ({
...prev,
[group.key]: {
error: messageFromLibraryBrowseError(error, 'Unable to load more items'),
items: current?.items ?? [],
loading: false,
nextPageNum
}
}));
});
};
const toggle = (item: LibraryBrowseItem) => { const toggle = (item: LibraryBrowseItem) => {
const mediaItemId = mediaItemIdOf(item); const mediaItemId = mediaItemIdOf(item);
if (mediaItemId == null) { if (mediaItemId == null) {
@@ -113,7 +164,8 @@ export function TrashScreen() {
} }
const ids = new Set<number>(); const ids = new Set<number>();
for (const group of GROUPS) { for (const group of GROUPS) {
for (const item of state.results[group.key].items) { const items = [...state.results[group.key].items, ...(seeAll[group.key]?.items ?? [])];
for (const item of items) {
const mediaItemId = mediaItemIdOf(item); const mediaItemId = mediaItemIdOf(item);
if (mediaItemId != null) { if (mediaItemId != null) {
ids.add(mediaItemId); ids.add(mediaItemId);
@@ -219,9 +271,12 @@ export function TrashScreen() {
state.results && state.results &&
GROUPS.map((group) => { GROUPS.map((group) => {
const data = state.results![group.key]; const data = state.results![group.key];
if (data.totalCount === 0 || data.items.length === 0) { const more = seeAll[group.key];
const items = [...data.items, ...(more?.items ?? [])];
if (data.totalCount === 0 || items.length === 0) {
return null; return null;
} }
const hasMore = items.length < data.totalCount;
return ( return (
<section key={group.key}> <section key={group.key}>
@@ -232,7 +287,7 @@ export function TrashScreen() {
</span> </span>
</div> </div>
<div className="ctv-media-grid"> <div className="ctv-media-grid">
{data.items.map((item) => { {items.map((item) => {
const mediaItemId = mediaItemIdOf(item); const mediaItemId = mediaItemIdOf(item);
return ( return (
<MediaPosterCard <MediaPosterCard
@@ -244,6 +299,19 @@ export function TrashScreen() {
); );
})} })}
</div> </div>
{more?.error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{more.error}</span>
</div>
)}
{hasMore && (
<div className="ctv-media-section-footer">
<Button disabled={more?.loading === true} onClick={() => loadMore(group)} size="sm" variant="secondary">
{more?.loading ? 'Loading…' : `See all ${data.totalCount} ${group.label.toLowerCase()}`}
</Button>
</div>
)}
</section> </section>
); );
})} })}
+26
View File
@@ -841,6 +841,26 @@ body {
vertical-align: middle; vertical-align: middle;
} }
.ctv-logs-sort-button {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
margin: 0;
padding: 0;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.ctv-logs-sort-button:hover,
.ctv-logs-sort-button:focus-visible {
color: var(--text-primary);
}
.ctv-channel-check { .ctv-channel-check {
width: 38px; width: 38px;
padding-left: 14px !important; padding-left: 14px !important;
@@ -2924,6 +2944,12 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
} }
.ctv-media-section-footer {
display: flex;
justify-content: center;
margin: var(--space-5, 10px) 0 var(--space-7, 16px);
}
/* lineup */ /* lineup */
.ctv-builder-lineup-list { .ctv-builder-lineup-list {
display: flex; display: flex;