diff --git a/ErsatzTV.Tests/Controllers/LogsControllerTests.cs b/ErsatzTV.Tests/Controllers/LogsControllerTests.cs index 467b82f30..e3cd79d12 100644 --- a/ErsatzTV.Tests/Controllers/LogsControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/LogsControllerTests.cs @@ -42,7 +42,7 @@ public class LogsControllerTests _mediator.Send(Arg.Any(), Arg.Any()) .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(q => @@ -52,6 +52,74 @@ public class LogsControllerTests Arg.Any()); } + [Test] + public async Task GetLogs_Should_Default_To_Timestamp_Descending() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(cancellationToken: CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => + q.SortDescending == true && + SelectsTimestamp(q.SortExpression)), + Arg.Any()); + } + + [Test] + public async Task GetLogs_Should_Sort_By_Level_Ascending_When_Requested() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(sortField: "level", sortDirection: "asc", cancellationToken: CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => + q.SortDescending == false && + SelectsLevel(q.SortExpression)), + Arg.Any()); + } + + [Test] + public async Task GetLogs_Should_Reject_Unknown_Sort_Field_And_Fall_Back_To_Timestamp() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(sortField: "message; DROP TABLE", cancellationToken: CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => SelectsTimestamp(q.SortExpression)), + Arg.Any()); + } + + [Test] + public async Task GetLogs_Should_Fall_Back_To_Descending_For_Unknown_Direction() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLogEntriesViewModel(0, [])); + + await _controller.GetLogs(sortDirection: "sideways", cancellationToken: CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => q.SortDescending == true), + Arg.Any()); + } + + private static bool SelectsTimestamp(System.Linq.Expressions.Expression> 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> 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() { diff --git a/ErsatzTV/Controllers/Api/LogsController.cs b/ErsatzTV/Controllers/Api/LogsController.cs index 46c6a1620..19e4aadf9 100644 --- a/ErsatzTV/Controllers/Api/LogsController.cs +++ b/ErsatzTV/Controllers/Api/LogsController.cs @@ -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 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 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> 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( diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 847e90c5a..7899cea28 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -5410,6 +5410,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": [ { @@ -5437,6 +5438,22 @@ "type": "string", "default": "" } + }, + { + "name": "sortField", + "in": "query", + "schema": { + "type": "string", + "default": "timestamp" + } + }, + { + "name": "sortDirection", + "in": "query", + "schema": { + "type": "string", + "default": "desc" + } } ], "responses": { diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 2a89fe5b0..72d918bd7 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -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 diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index bb617695a..9cbd8e316 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -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) | — | -> | 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) | #213 | > | Search | PARITY-OK (card nav, per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10) | — | > | 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) | — | @@ -181,8 +181,8 @@ 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). +#213 (remaining nits: block-history page-size/gating, blocks/templates list filter — read-only +conveniences). 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. @@ -192,6 +192,11 @@ query-wide Add All via `GET /api/search/all-items`, Save As Smart Collection) an pages + child grids, per-show Quick/Deep scan gated to Plex/Jellyfin/Emby, per-episode Media Info + Troubleshoot entries; `POST /api/playlists/{id}/items` added). Known accepted deviations (select-mode toggle, per-card target superset) recorded in `docs/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`). Remaining #213 scope: +block-history page-size/gating, blocks/templates list filter. ## Section 4 — Blazor home / escape hatch diff --git a/docs/decisions.md b/docs/decisions.md index 7dc4f6dec..f422d2b3a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 3e4b29422..1ab96bb94 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -118,6 +118,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. diff --git a/web/src/api/logs.test.ts b/web/src/api/logs.test.ts index c8cf43e09..1b21eacd7 100644 --- a/web/src/api/logs.test.ts +++ b/web/src/api/logs.test.ts @@ -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' }), { diff --git a/web/src/api/logs.ts b/web/src/api/logs.ts index 5d2269800..5b5983453 100644 --- a/web/src/api/logs.ts +++ b/web/src/api/logs.ts @@ -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 { @@ -25,6 +30,14 @@ export function getLogs(params: GetLogsParams = {}): Promise { 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(`/api/logs${queryString ? `?${queryString}` : ''}`); diff --git a/web/src/screens/LogsScreen.test.tsx b/web/src/screens/LogsScreen.test.tsx new file mode 100644 index 000000000..622df0b2f --- /dev/null +++ b/web/src/screens/LogsScreen.test.tsx @@ -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): 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(); + + 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(); + 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(); + 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(); + 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(); + await screen.findByText('uh oh'); + + expect(screen.getByDisplayValue('100')).toBeInTheDocument(); + expect(lastLogsUrl(fetchMock)).toContain('pageSize=100'); + }); +}); diff --git a/web/src/screens/LogsScreen.tsx b/web/src/screens/LogsScreen.tsx index e1f96a25f..001988f5e 100644 --- a/web/src/screens/LogsScreen.tsx +++ b/web/src/screens/LogsScreen.tsx @@ -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 = { 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('timestamp'); + const [sortDirection, setSortDirection] = useState('desc'); const [state, setState] = useState({ 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() { />