Merge remote-tracking branch 'origin/fix/213-logs-trash' into integrate/review-gates
# Conflicts: # docs/blazor-route-parity.md
This commit is contained in:
@@ -42,7 +42,7 @@ public class LogsControllerTests
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.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(
|
||||
Arg.Is<GetRecentLogEntries>(q =>
|
||||
@@ -52,6 +52,74 @@ public class LogsControllerTests
|
||||
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]
|
||||
public async Task GetLogs_Should_Map_Entries_To_Response_Model()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq.Expressions;
|
||||
using ErsatzTV.Application.Logs;
|
||||
using ErsatzTV.Core.Api.Logs;
|
||||
using MediatR;
|
||||
@@ -11,22 +12,48 @@ public class LogsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
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")]
|
||||
[Tags("Logs")]
|
||||
[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")]
|
||||
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedLogEntriesResponseModel> GetLogs(
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] string filter = "",
|
||||
[FromQuery] string sortField = "timestamp",
|
||||
[FromQuery] string sortDirection = "desc",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
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(
|
||||
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty),
|
||||
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
|
||||
{
|
||||
SortExpression = sortExpression,
|
||||
SortDescending = descending
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return new PagedLogEntriesResponseModel(
|
||||
|
||||
@@ -5470,6 +5470,7 @@
|
||||
"Logs"
|
||||
],
|
||||
"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",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5497,6 +5498,22 @@
|
||||
"type": "string",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortField",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "timestamp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortDirection",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "desc"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -32,6 +32,12 @@ Exemplars:
|
||||
`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
|
||||
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
|
||||
|
||||
|
||||
+13
-10
@@ -60,10 +60,10 @@ redirect).
|
||||
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
|
||||
> | 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) | — |
|
||||
> | 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) | — |
|
||||
> | 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) | — |
|
||||
> | 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) | — |
|
||||
@@ -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
|
||||
|
||||
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,
|
||||
blocks/templates list filter — all read-only conveniences).
|
||||
2026-07-11 (branch `fix/213-spa-nits`): block-history page-size persistence (`localStorage`,
|
||||
key `ctv-block-history-page-size`, same `ctv-` namespace as `ctv-theme`) + gating the History
|
||||
action on `block.id >= 0` (`BlockPlayoutTroubleshootingScreen.tsx`), and client-side name/group
|
||||
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`).
|
||||
#213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
|
||||
persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as
|
||||
`ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on
|
||||
the Blocks and Templates lists (`fix/213-spa-nits`); logs column sorting + page-size persistence
|
||||
and trash per-kind "see all" paging (`fix/213-logs-trash`).
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
Not gated on an issue — kept separate from Section 3 because it isn't blocked on anything, just the
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
|
||||
|
||||
@@ -44,6 +44,20 @@ describe('getLogs', () => {
|
||||
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 () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
|
||||
|
||||
@@ -4,10 +4,15 @@ import type { components } from './generated/v1';
|
||||
export type LogEntry = components['schemas']['LogEntryResponseModel'];
|
||||
export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel'];
|
||||
|
||||
export type LogsSortField = 'timestamp' | 'level';
|
||||
export type LogsSortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface GetLogsParams {
|
||||
filter?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
sortDirection?: LogsSortDirection;
|
||||
sortField?: LogsSortField;
|
||||
}
|
||||
|
||||
export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
|
||||
@@ -25,6 +30,14 @@ export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
|
||||
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();
|
||||
|
||||
return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,23 @@
|
||||
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 { 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 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'> = {
|
||||
Debug: 'neutral',
|
||||
@@ -24,11 +38,44 @@ function formatTimestamp(value: string): string {
|
||||
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() {
|
||||
const [filterInput, setFilterInput] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
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 activeRef = useRef(true);
|
||||
const seqRef = useRef(0);
|
||||
@@ -56,7 +103,7 @@ export function LogsScreen() {
|
||||
// a loading spinner on every filter keystroke or page change.
|
||||
const load = useCallback(() => {
|
||||
const id = ++seqRef.current;
|
||||
getLogs({ filter, pageNum, pageSize })
|
||||
getLogs({ filter, pageNum, pageSize, sortDirection, sortField })
|
||||
.then((paged) => {
|
||||
if (activeRef.current && id === seqRef.current) {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}, [filter, pageNum, pageSize]);
|
||||
}, [filter, pageNum, pageSize, sortDirection, sortField]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -78,6 +125,22 @@ export function LogsScreen() {
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -91,10 +154,7 @@ export function LogsScreen() {
|
||||
/>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Select
|
||||
onChange={(event) => {
|
||||
setPageNum(0);
|
||||
setPageSize(Number(event.target.value));
|
||||
}}
|
||||
onChange={(event) => changePageSize(Number(event.target.value))}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
style={{ width: 96 }}
|
||||
value={String(pageSize)}
|
||||
@@ -136,8 +196,26 @@ export function LogsScreen() {
|
||||
<table aria-label="Recent log entries" className="ctv-channels-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Level</th>
|
||||
{COLUMNS.map((column) => {
|
||||
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>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -44,6 +44,12 @@ const searchResults = {
|
||||
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 {
|
||||
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
||||
}
|
||||
@@ -129,4 +135,37 @@ describe('TrashScreen', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,20 @@ import { Button, Card, ConfirmDialog, Spinner } from '../components';
|
||||
import {
|
||||
deleteMediaItems,
|
||||
emptyTrash,
|
||||
getLibraryBrowseItems,
|
||||
getSearchResults,
|
||||
messageFromLibraryBrowseError,
|
||||
messageFromSearchError,
|
||||
type LibraryBrowseItem,
|
||||
type LibraryBrowseMediaType,
|
||||
type SearchResults
|
||||
} from '../api';
|
||||
import { MediaPosterCard } from '../media/MediaPosterCard';
|
||||
|
||||
// The search API's SearchController clamps pageSize to MaxPageSize=100 and has no page-number
|
||||
// param, so this is the largest single request possible — see docs/decisions.md ("Trash see all").
|
||||
// The initial per-kind fetch uses /api/search (GetSearchResults), whose SearchController clamps
|
||||
// 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;
|
||||
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
|
||||
const TRASH_QUERY = 'state:FileNotFound';
|
||||
@@ -20,21 +25,30 @@ const TRASH_QUERY = 'state:FileNotFound';
|
||||
interface GroupDef {
|
||||
key: keyof SearchResults;
|
||||
label: string;
|
||||
mediaType: LibraryBrowseMediaType;
|
||||
}
|
||||
|
||||
const GROUPS: GroupDef[] = [
|
||||
{ key: 'movies', label: 'Movies' },
|
||||
{ key: 'shows', label: 'TV Shows' },
|
||||
{ key: 'seasons', label: 'Seasons' },
|
||||
{ key: 'episodes', label: 'Episodes' },
|
||||
{ key: 'artists', label: 'Artists' },
|
||||
{ key: 'musicVideos', label: 'Music Videos' },
|
||||
{ key: 'songs', label: 'Songs' },
|
||||
{ key: 'otherVideos', label: 'Other Videos' },
|
||||
{ key: 'images', label: 'Images' },
|
||||
{ key: 'remoteStreams', label: 'Remote Streams' }
|
||||
{ key: 'movies', label: 'Movies', mediaType: 'Movie' },
|
||||
{ key: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
|
||||
{ key: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' },
|
||||
{ key: 'episodes', label: 'Episodes', mediaType: 'Episode' },
|
||||
{ key: 'artists', label: 'Artists', mediaType: 'Artist' },
|
||||
{ key: 'musicVideos', label: 'Music Videos', mediaType: 'MusicVideo' },
|
||||
{ key: 'songs', label: 'Songs', mediaType: 'Song' },
|
||||
{ key: 'otherVideos', label: 'Other Videos', mediaType: 'OtherVideo' },
|
||||
{ key: 'images', label: 'Images', mediaType: 'Image' },
|
||||
{ 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 =
|
||||
| { results: SearchResults; error: null; status: 'success' }
|
||||
| { results: null; error: string; status: 'error' }
|
||||
@@ -46,6 +60,7 @@ function mediaItemIdOf(item: LibraryBrowseItem): number | null {
|
||||
|
||||
export function TrashScreen() {
|
||||
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 [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -83,9 +98,45 @@ export function TrashScreen() {
|
||||
|
||||
const refresh = () => {
|
||||
setState({ results: null, error: null, status: 'loading' });
|
||||
setSeeAll({});
|
||||
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 mediaItemId = mediaItemIdOf(item);
|
||||
if (mediaItemId == null) {
|
||||
@@ -113,7 +164,8 @@ export function TrashScreen() {
|
||||
}
|
||||
const ids = new Set<number>();
|
||||
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);
|
||||
if (mediaItemId != null) {
|
||||
ids.add(mediaItemId);
|
||||
@@ -219,9 +271,12 @@ export function TrashScreen() {
|
||||
state.results &&
|
||||
GROUPS.map((group) => {
|
||||
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;
|
||||
}
|
||||
const hasMore = items.length < data.totalCount;
|
||||
|
||||
return (
|
||||
<section key={group.key}>
|
||||
@@ -232,7 +287,7 @@ export function TrashScreen() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="ctv-media-grid">
|
||||
{data.items.map((item) => {
|
||||
{items.map((item) => {
|
||||
const mediaItemId = mediaItemIdOf(item);
|
||||
return (
|
||||
<MediaPosterCard
|
||||
@@ -244,6 +299,19 @@ export function TrashScreen() {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -841,6 +841,26 @@ body {
|
||||
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 {
|
||||
width: 38px;
|
||||
padding-left: 14px !important;
|
||||
@@ -2924,6 +2944,12 @@ body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-media-section-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: var(--space-5, 10px) 0 var(--space-7, 16px);
|
||||
}
|
||||
|
||||
/* lineup */
|
||||
.ctv-builder-lineup-list {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user