From eef6ae34629a01696efeb01508d57f35cd7c2c05 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 18 May 2026 08:29:32 -0400 Subject: [PATCH] Add admin system logs UI and API support Introduce a complete admin System Logs feature: adds frontend components (filters, date picker, active filter chips, rows, detail panel, skeletons, pagination, toolbar, user typeahead, and styles) under src/app/admin/logs/components, plus hooks (useAutoRefreshControl, useLogsUrlState, useUserSearch) and types. Add constants for job labels and log filters, wire URL-driven filters/search/date-range/hasError/user/audiobookQuery with pause-on-interact behavior and page-size options. Update API route (/api/admin/logs) to support the expanded query params and exported where-builder. Update documentation (TABLEOFCONTENTS and admin-dashboard) and add/adjust tests for the new admin logs UI and API behavior. --- documentation/TABLEOFCONTENTS.md | 4 +- documentation/admin-dashboard.md | 37 +- .../logs/components/ActiveFilterChips.tsx | 147 ++++ .../admin/logs/components/DateRangePicker.tsx | 144 ++++ .../admin/logs/components/LogDetailPanel.tsx | 314 ++++++++ src/app/admin/logs/components/LogRow.tsx | 318 ++++++++ src/app/admin/logs/components/LogSkeleton.tsx | 82 +++ src/app/admin/logs/components/LogsFilters.tsx | 171 +++++ .../admin/logs/components/LogsPagination.tsx | 140 ++++ src/app/admin/logs/components/LogsToolbar.tsx | 203 ++++++ .../admin/logs/components/UserTypeahead.tsx | 165 +++++ .../admin/logs/components/filter-styles.ts | 16 + .../admin/logs/hooks/useAutoRefreshControl.ts | 210 ++++++ src/app/admin/logs/hooks/useLogsUrlState.ts | 278 +++++++ src/app/admin/logs/hooks/useUserSearch.ts | 88 +++ src/app/admin/logs/page.tsx | 683 ++++++------------ src/app/admin/logs/types.ts | 200 +++++ src/app/api/admin/logs/route.ts | 146 +++- src/lib/constants/job-labels.ts | 26 + src/lib/constants/log-filters.ts | 130 ++++ tests/api/admin-logs.routes.test.ts | 215 +++++- tests/app/admin-logs-chips.test.tsx | 140 ++++ tests/app/admin-logs-filters.test.tsx | 214 ++++++ tests/app/admin-logs.page.test.tsx | 634 +++++++++++++--- 24 files changed, 4123 insertions(+), 582 deletions(-) create mode 100644 src/app/admin/logs/components/ActiveFilterChips.tsx create mode 100644 src/app/admin/logs/components/DateRangePicker.tsx create mode 100644 src/app/admin/logs/components/LogDetailPanel.tsx create mode 100644 src/app/admin/logs/components/LogRow.tsx create mode 100644 src/app/admin/logs/components/LogSkeleton.tsx create mode 100644 src/app/admin/logs/components/LogsFilters.tsx create mode 100644 src/app/admin/logs/components/LogsPagination.tsx create mode 100644 src/app/admin/logs/components/LogsToolbar.tsx create mode 100644 src/app/admin/logs/components/UserTypeahead.tsx create mode 100644 src/app/admin/logs/components/filter-styles.ts create mode 100644 src/app/admin/logs/hooks/useAutoRefreshControl.ts create mode 100644 src/app/admin/logs/hooks/useLogsUrlState.ts create mode 100644 src/app/admin/logs/hooks/useUserSearch.ts create mode 100644 src/app/admin/logs/types.ts create mode 100644 src/lib/constants/job-labels.ts create mode 100644 src/lib/constants/log-filters.ts create mode 100644 tests/app/admin-logs-chips.test.tsx create mode 100644 tests/app/admin-logs-filters.test.tsx diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 0e3deb2..5546c01 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -6,7 +6,6 @@ - **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md) - **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md) - **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md) -- **API tokens (allowlist, write capability, /api-docs)** → [backend/services/api-tokens.md](backend/services/api-tokens.md) - **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md) - **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md) - **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md) @@ -60,7 +59,6 @@ - **Ebook delete behavior (files only, torrents seed)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior) - **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar) - **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed) -- **Auto-search behavior toggle (skip unreleased books)** → [settings-pages.md](settings-pages.md#auto-search-behavior-indexers-tab) ## Automation Pipeline - **Full pipeline overview** → [phase3/README.md](phase3/README.md) @@ -70,7 +68,6 @@ - **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md) - **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md) - **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md) -- **Plex-compatible format coercion (.mp4 → .m4b)** → [phase3/file-organization.md](phase3/file-organization.md#plex-format-coercion) - **Chapter merging (auto-merge to M4B)** → [features/chapter-merging.md](features/chapter-merging.md) ## Background Jobs @@ -105,6 +102,7 @@ ## Admin Features - **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md) +- **System logs (filters, search, pagination, /api/admin/logs)** → [admin-dashboard.md](admin-dashboard.md) - **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md) - **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md) - **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md) diff --git a/documentation/admin-dashboard.md b/documentation/admin-dashboard.md index c67326f..f78d3c3 100644 --- a/documentation/admin-dashboard.md +++ b/documentation/admin-dashboard.md @@ -57,9 +57,18 @@ Comprehensive overview of system metrics, active requests, download monitoring, - Update global auto-approve setting (boolean) **GET /api/admin/logs** -- Query params: page, limit, status, type -- Returns: Job logs with request/audiobook/user details, pagination info -- Filters: status (all/pending/active/completed/failed/delayed/stuck), type (all job types) +- Query params: page, limit, status, type, search, dateFrom, dateTo, hasError, userId, audiobookQuery +- limit: one of 25/50/100 (default 50; invalid values clamp to 50) +- status: 'all' or one of pending/active/completed/failed/delayed/stuck +- type: 'all' or any job type key +- dateFrom / dateTo: ISO UTC strings; invalid dates silently dropped +- hasError: 'true' or '1' → `status in (failed, stuck) OR errorMessage IS NOT NULL` +- userId: uuid → filters via `request.userId` +- audiobookQuery: free text → OR-contains (case-insensitive) on `request.audiobook.{title,author}` +- search: free text → 6-column OR: bullJobId (startsWith, case-sensitive), errorMessage (contains-i), events.some.message (contains-i), request.audiobook.title/author (contains-i), request.user.plexUsername (contains-i) +- hasError + search combine under top-level `AND`; other filters compose via AND on `where` +- Where-builder: exported `buildLogsWhere(params)` in route file (pure, testable) +- Returns: `{ logs, pagination: { page, limit, total, totalPages } }` ## Request Management Features @@ -112,15 +121,21 @@ Comprehensive overview of system metrics, active requests, download monitoring, ## System Logs Features -- Real-time job monitoring (10s refresh) -- Filter by status (pending/active/completed/failed/delayed/stuck) -- Filter by job type (search_indexers/monitor_download/organize_files/scan_plex/match_plex) +- Real-time job monitoring (10s SWR refresh; pauses on interact) +- **Filter row (5 pickers):** Status · Job Type · Date Range · User typeahead · Audiobook free-text + - Status: dropdown over VALID_STATUSES (from `src/app/admin/logs/types.ts`); labels via `STATUS_OPTIONS` in `src/lib/constants/log-filters.ts` + - Job Type: dropdown over `JOB_TYPE_LABELS` insertion order (`src/lib/constants/job-labels.ts`) + - Date Range: presets (Last hour / 24h / 7d / 30d / Custom / All time) — default = Last 7 days (Zach #1); Custom uses `` rendered as local time, wired as UTC ISO + - User: typeahead via `useUserSearch` (fetch-once from `/api/admin/users`, SWR-cached, in-memory filter, max 10 suggestions); selection sets `userId = User.id` + - Audiobook: free-text → server-side OR-contains on title/author (Zach #4 — no picker) +- **Active filter chips:** dismissable ` + ); +} + +function formatDateChipLabel(dateFrom: string | null, dateTo: string | null): string { + const presetId = getActivePresetId(dateFrom, dateTo); + if (presetId === 'custom') { + return `${formatLocal(dateFrom)} – ${formatLocal(dateTo)}`; + } + const preset = DATE_PRESETS.find((p) => p.id === presetId); + return preset?.label ?? 'Custom'; +} + +function formatLocal(iso: string | null): string { + if (!iso) return '…'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '…'; + return d.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/app/admin/logs/components/DateRangePicker.tsx b/src/app/admin/logs/components/DateRangePicker.tsx new file mode 100644 index 0000000..6324cdc --- /dev/null +++ b/src/app/admin/logs/components/DateRangePicker.tsx @@ -0,0 +1,144 @@ +/** + * Component: Admin Logs — Date Range Picker + * Documentation: documentation/admin-dashboard.md + * + * Compact preset fields for Custom mode. Local times entered + * are converted to UTC ISO before being emitted on the wire. + * + * Pause-on-interact: registers `'logs-date-picker'` while the picker subtree + * has focus. + */ + +'use client'; + +import { useMemo, useState } from 'react'; +import { + DATE_PRESETS, + getActivePresetId, + presetToRange, + type DatePresetId, +} from '@/lib/constants/log-filters'; +import { useRegisterPauseReason } from '../hooks/useAutoRefreshControl'; +import { INPUT_CLASS, LABEL_CLASS } from './filter-styles'; + +interface DateRangePickerProps { + dateFrom: string | null; + dateTo: string | null; + onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void; +} + +export default function DateRangePicker({ dateFrom, dateTo, onChange }: DateRangePickerProps) { + const [focused, setFocused] = useState(false); + useRegisterPauseReason('logs-date-picker', focused); + + // Force-custom keeps the datetime-local inputs visible while the user is + // entering values — without it, derived state (both null) would snap back + // to "all_time" the moment they pick Custom but before they type anything. + const [forceCustom, setForceCustom] = useState(false); + const derivedPreset = useMemo( + () => getActivePresetId(dateFrom, dateTo), + [dateFrom, dateTo] + ); + const activePreset: DatePresetId = forceCustom ? 'custom' : derivedPreset; + const showCustom = activePreset === 'custom'; + + const handlePresetChange = (id: DatePresetId) => { + if (id === 'custom') { + setForceCustom(true); + return; + } + setForceCustom(false); + onChange(presetToRange(id)); + }; + + const handleCustomChange = (next: { dateFrom: string | null; dateTo: string | null }) => { + setForceCustom(true); + onChange(next); + }; + + return ( +
setFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setFocused(false); + } + }} + > + + + {showCustom && ( + + )} +
+ ); +} + +function CustomDateInputs({ + dateFrom, + dateTo, + onChange, +}: { + dateFrom: string | null; + dateTo: string | null; + onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void; +}) { + const fromLocal = useMemo(() => isoToLocalInputValue(dateFrom), [dateFrom]); + const toLocal = useMemo(() => isoToLocalInputValue(dateTo), [dateTo]); + + return ( +
+
+ + onChange({ dateFrom: localInputToIso(e.target.value), dateTo }) + } + className={INPUT_CLASS} + /> + + onChange({ dateFrom, dateTo: localInputToIso(e.target.value) }) + } + className={INPUT_CLASS} + /> +
+

+ Times are in your local timezone (sent as UTC). +

+
+ ); +} + +function isoToLocalInputValue(iso: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `T${pad(d.getHours())}:${pad(d.getMinutes())}` + ); +} + +function localInputToIso(value: string): string | null { + if (!value) return null; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} diff --git a/src/app/admin/logs/components/LogDetailPanel.tsx b/src/app/admin/logs/components/LogDetailPanel.tsx new file mode 100644 index 0000000..93a1e38 --- /dev/null +++ b/src/app/admin/logs/components/LogDetailPanel.tsx @@ -0,0 +1,314 @@ +/** + * Component: LogDetailPanel + * Documentation: documentation/admin-dashboard.md + * + * Three collapsible sub-sections (Event Log / Result / Error) with count badges. + * Per-event level filter. Copy-to-clipboard on each event, full event log, + * result JSON, error block, and Bull Job ID. Toast confirmations. + * Default open on desktop (`defaultOpen` prop), collapsed on mobile. + * + * NO "View related request" link — no admin request detail page exists (Zach #4). + */ + +'use client'; + +import { useMemo, useState } from 'react'; +import { useToast } from '@/components/ui/Toast'; +import { JobEvent, Log } from '../types'; + +type Level = 'all' | 'info' | 'warn' | 'error'; + +// =========================================================================== +// CopyButton — extracted because used 5+ times +// =========================================================================== + +interface CopyButtonProps { + text: string; + label: string; + className?: string; + /** When true, render as a compact icon-only button. */ + iconOnly?: boolean; +} + +function CopyButton({ text, label, className, iconOnly = false }: CopyButtonProps) { + const toast = useToast(); + + const handleClick = async () => { + const ok = await copyToClipboard(text); + if (ok) toast.success(`Copied ${label}`); + else toast.error('Copy unavailable on insecure connection'); + }; + + return ( + + ); +} + +async function copyToClipboard(text: string): Promise { + if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // fall through to textarea fallback + } + } + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '0'; + ta.style.left = '0'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + +// =========================================================================== +// EventLine — single row in the event log +// =========================================================================== + +function levelColorClass(level: string): string { + if (level === 'error') return 'text-red-400'; + if (level === 'warn') return 'text-amber-400'; + return 'text-emerald-400'; +} + +function formatEventLine(e: JobEvent): string { + const ts = (() => { + try { + return new Date(e.createdAt).toISOString().split('T')[1].split('.')[0]; + } catch { + return e.createdAt; + } + })(); + const meta = e.metadata && Object.keys(e.metadata).length > 0 + ? '\n' + JSON.stringify(e.metadata, null, 2) + : ''; + return `${ts} [${e.level.toUpperCase()}] [${e.context}] ${e.message}${meta}`; +} + +function EventLine({ event }: { event: JobEvent }) { + const ts = (() => { + try { + return new Date(event.createdAt).toISOString().split('T')[1].split('.')[0]; + } catch { + return event.createdAt; + } + })(); + return ( +
+ [{event.context}]{' '} + {event.message} + {ts} + {event.metadata && Object.keys(event.metadata).length > 0 && ( +
+          {JSON.stringify(event.metadata, null, 2)}
+        
+ )} +
+ +
+
+ ); +} + +// =========================================================================== +// Collapsible — a sub-section with title, count badge, chevron toggle +// =========================================================================== + +interface CollapsibleProps { + title: string; + count?: number; + defaultOpen: boolean; + children: React.ReactNode; + headerRight?: React.ReactNode; +} + +function Collapsible({ title, count, defaultOpen, children, headerRight }: CollapsibleProps) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+
+ + {open && headerRight} +
+ {open && children} +
+ ); +} + +// =========================================================================== +// LogDetailPanel +// =========================================================================== + +interface LogDetailPanelProps { + log: Log; + /** Default-open state for the three sub-sections. Desktop: true; Mobile: false. */ + defaultOpen: boolean; +} + +export function LogDetailPanel({ log, defaultOpen }: LogDetailPanelProps) { + const [level, setLevel] = useState('all'); + + const filteredEvents = useMemo(() => { + if (level === 'all') return log.events; + return log.events.filter((e) => e.level === level); + }, [log.events, level]); + + const fullEventLog = useMemo( + () => log.events.map(formatEventLine).join('\n'), + [log.events] + ); + + const resultText = useMemo( + () => (log.result ? JSON.stringify(log.result, null, 2) : ''), + [log.result] + ); + + const hasResult = !!(log.result && Object.keys(log.result).length > 0); + + return ( +
+ {log.bullJobId && ( +
+ + Bull Job ID: + + + {log.bullJobId} + + +
+ )} + + {log.events.length > 0 && ( + + + +
+ } + > + {filteredEvents.length === 0 ? ( +
+ No events at level "{level}". +
+ ) : ( +
+ {filteredEvents.map((event) => ( + + ))} +
+ )} + + )} + + {hasResult && ( + } + > +
+            {resultText}
+          
+
+ )} + + {log.errorMessage && ( + } + > +
+ {log.errorMessage} +
+
+ )} + + ); +} + +// =========================================================================== +// LevelFilterPills — small group toggle +// =========================================================================== + +function LevelFilterPills({ + value, + onChange, +}: { + value: Level; + onChange: (next: Level) => void; +}) { + const options: { key: Level; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'info', label: 'Info' }, + { key: 'warn', label: 'Warn' }, + { key: 'error', label: 'Error' }, + ]; + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} diff --git a/src/app/admin/logs/components/LogRow.tsx b/src/app/admin/logs/components/LogRow.tsx new file mode 100644 index 0000000..f5dadaf --- /dev/null +++ b/src/app/admin/logs/components/LogRow.tsx @@ -0,0 +1,318 @@ +/** + * Component: LogRow (Desktop + Mobile wrappers + shared cell helpers) + * Documentation: documentation/admin-dashboard.md + * + * One file, one source of truth for cell logic, two layout shells: + * - → renders (inside the desktop table) + * - → renders
(inside the mobile card list) + * Cell helpers (, , , etc.) are pure and used + * by both shells. No duplicated logic; layout split is just JSX containers. + * + * Disclosure: real + ); +} + +// =========================================================================== +// Shared expansion + clock state hook +// =========================================================================== + +function useRowState(log: Log) { + const [expanded, setExpanded] = useState(false); + const { register, unregister } = useAutoRefreshControl(); + + // While this row is expanded, register a pause reason. + useEffect(() => { + if (!expanded) return; + const reason = `row-expanded:${log.id}`; + register(reason); + return () => unregister(reason); + }, [expanded, log.id, register, unregister]); + + const detailPanelId = `log-detail-${log.id}`; + const toggle = () => setExpanded((v) => !v); + return { expanded, toggle, detailPanelId }; +} + +function useNowTick(intervalMs = 30_000): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return now; +} + +// =========================================================================== +// Desktop wrapper — +// =========================================================================== + +interface RowProps { + log: Log; +} + +function LogRowDesktop({ log }: RowProps) { + const { expanded, toggle, detailPanelId } = useRowState(log); + const now = useNowTick(); + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + {expanded && ( + + + + + + )} + + ); +} + +// =========================================================================== +// Mobile wrapper —
+// =========================================================================== + +function LogRowMobile({ log }: RowProps) { + const { expanded, toggle, detailPanelId } = useRowState(log); + const now = useNowTick(); + const hasDetails = logHasDetails(log); + return ( +
+
+
+
+ +
+ +
+
+ +
+
+ + Duration: {formatDuration(log.startedAt, log.completedAt)} + Attempts: {log.attempts}/{log.maxAttempts} +
+
+ {hasDetails && ( + <> +
+ + {expanded ? 'Hide details' : 'Show details'} + + +
+ {expanded && ( +
+ +
+ )} + + )} +
+ ); +} + +// =========================================================================== +// Public exports +// =========================================================================== + +export const LogRow = { + Desktop: LogRowDesktop, + Mobile: LogRowMobile, +}; diff --git a/src/app/admin/logs/components/LogSkeleton.tsx b/src/app/admin/logs/components/LogSkeleton.tsx new file mode 100644 index 0000000..bc3fb72 --- /dev/null +++ b/src/app/admin/logs/components/LogSkeleton.tsx @@ -0,0 +1,82 @@ +/** + * Component: LogSkeleton + * Documentation: documentation/admin-dashboard.md + * + * Shape-matched skeleton rows. Shown only on initial load (`!data`) or on + * filter-key transition — never during auto-refresh (which preserves rows). + * + * Layout intentionally mirrors LogRow so swap is reflow-free. + */ + +'use client'; + +interface LogSkeletonProps { + /** How many skeleton rows to render. Default 6. */ + count?: number; +} + +export function LogSkeleton({ count = 6 }: LogSkeletonProps) { + const items = Array.from({ length: count }, (_, i) => i); + return ( + <> + {/* Mobile card skeletons */} +
+ {items.map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* Desktop table skeletons */} +
+ + + {items.map((i) => ( + + + + + + + + + + ))} + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + ); +} diff --git a/src/app/admin/logs/components/LogsFilters.tsx b/src/app/admin/logs/components/LogsFilters.tsx new file mode 100644 index 0000000..5d5be63 --- /dev/null +++ b/src/app/admin/logs/components/LogsFilters.tsx @@ -0,0 +1,171 @@ +/** + * Component: Admin Logs — Filter Picker Row + * Documentation: documentation/admin-dashboard.md + * + * Composition of five picker controls in a responsive grid plus a + * "Clear all filters" affordance. Heavier controls (DateRangePicker and + * UserTypeahead) live in sibling files to keep this composition file + * comfortably under the per-file size cap. + * + * Status select · Job Type select · Date Range · User typeahead · Audiobook text + * + * Each control registers a unique pause-on-interact reason so the page-level + * auto-refresh halts while the admin is mid-interaction. + * + * Consumes useLogsUrlState() directly — no prop drilling. + */ + +'use client'; + +import { useState } from 'react'; +import { JOB_TYPE_LABELS } from '@/lib/constants/job-labels'; +import { STATUS_OPTIONS } from '@/lib/constants/log-filters'; +import { hasActiveFilters, hasActiveSearch } from '../types'; +import { useRegisterPauseReason } from '../hooks/useAutoRefreshControl'; +import { useLogsUrlState } from '../hooks/useLogsUrlState'; +import DateRangePicker from './DateRangePicker'; +import UserTypeahead from './UserTypeahead'; +import { INPUT_CLASS, LABEL_CLASS } from './filter-styles'; + +export default function LogsFilters() { + const { filters, setFilters, clearAll } = useLogsUrlState(); + const showClearAll = hasActiveFilters(filters) || hasActiveSearch(filters); + + return ( +
+
+ setFilters({ status: value })} + /> + setFilters({ type: value })} + /> + setFilters(next)} + /> + setFilters({ userId: id })} + /> + setFilters({ audiobookQuery: value })} + /> +
+ {showClearAll && ( +
+ +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Status dropdown +// --------------------------------------------------------------------------- +function StatusDropdown({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const [focused, setFocused] = useState(false); + useRegisterPauseReason('logs-status-dropdown', focused); + return ( +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Job-type dropdown +// --------------------------------------------------------------------------- +function JobTypeDropdown({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const [focused, setFocused] = useState(false); + useRegisterPauseReason('logs-type-dropdown', focused); + return ( +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Audiobook free-text input (matches title OR author server-side) +// --------------------------------------------------------------------------- +function AudiobookInput({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const [focused, setFocused] = useState(false); + useRegisterPauseReason('logs-book-input', focused); + return ( +
+ + onChange(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + placeholder="Title or author" + className={INPUT_CLASS} + /> +
+ ); +} diff --git a/src/app/admin/logs/components/LogsPagination.tsx b/src/app/admin/logs/components/LogsPagination.tsx new file mode 100644 index 0000000..4d2381e --- /dev/null +++ b/src/app/admin/logs/components/LogsPagination.tsx @@ -0,0 +1,140 @@ +/** + * Component: LogsPagination + * Documentation: documentation/admin-dashboard.md + * + * Prev/next + jump-to-page + page-size selector + "Page X of Y · N total logs". + * Keyboard accessible. Each interactive element ≥ 44×44 touch target. + * Reading the page-size opens registers a pause-on-interact reason. + */ + +'use client'; + +import { useEffect, useState } from 'react'; +import { VALID_LIMITS, ValidLimit, LogsPagination as PaginationData } from '../types'; +import { useAutoRefreshControl } from '../hooks/useAutoRefreshControl'; + +interface LogsPaginationProps { + pagination: PaginationData; + onPageChange: (next: number) => void; + onLimitChange: (next: ValidLimit) => void; +} + +export function LogsPagination({ + pagination, + onPageChange, + onLimitChange, +}: LogsPaginationProps) { + const { page, limit, total, totalPages } = pagination; + const [jumpValue, setJumpValue] = useState(String(page)); + const [limitFocused, setLimitFocused] = useState(false); + const { register, unregister } = useAutoRefreshControl(); + + // Keep jump input in sync when page changes from outside. + useEffect(() => { + setJumpValue(String(page)); + }, [page]); + + // Pause auto-refresh while the limit dropdown is focused/open. + useEffect(() => { + if (limitFocused) register('page-size-dropdown'); + else unregister('page-size-dropdown'); + return () => unregister('page-size-dropdown'); + }, [limitFocused, register, unregister]); + + const submitJump = () => { + const parsed = Number.parseInt(jumpValue, 10); + if (!Number.isFinite(parsed)) { + setJumpValue(String(page)); + return; + } + const clamped = Math.min(Math.max(1, parsed), Math.max(1, totalPages)); + if (clamped !== page) onPageChange(clamped); + setJumpValue(String(clamped)); + }; + + return ( +
+ {/* Summary + limit */} +
+ + Page {page} of{' '} + {Math.max(1, totalPages)} + {' · '} + + {total.toLocaleString()} + {' '} + {total === 1 ? 'log' : 'logs'} + + + +
+ + {/* Nav controls */} +
+ + + + + +
+
+ ); +} diff --git a/src/app/admin/logs/components/LogsToolbar.tsx b/src/app/admin/logs/components/LogsToolbar.tsx new file mode 100644 index 0000000..3b2e6c6 --- /dev/null +++ b/src/app/admin/logs/components/LogsToolbar.tsx @@ -0,0 +1,203 @@ +/** + * Component: LogsToolbar + * Documentation: documentation/admin-dashboard.md + * + * Sticky header. Three rows on mobile, condensed to two on sm+: + * 1. Title + description (left), Back-to-dashboard (right) + * 2. Errors-only pill, Live indicator, Refresh now, Auto-refresh toggle + * 3. Search input (always visible on mobile, debounced 300ms via the URL hook) + * + * Chips (ben-filters) and filter dropdowns (ben-filters) render OUTSIDE this + * toolbar (in page.tsx) so they scroll away on mobile per Zach resolution #6. + */ + +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useLogsUrlState } from '../hooks/useLogsUrlState'; +import { useAutoRefreshControl } from '../hooks/useAutoRefreshControl'; + +function formatRelativeSeconds(ts: number, now: number): string { + if (ts === 0) return '—'; + const elapsedMs = Math.max(0, now - ts); + const s = Math.floor(elapsedMs / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + return `${h}h ago`; +} + +export function LogsToolbar() { + const { filters, setFilters, searchInput, setSearchInput, removeFilter } = + useLogsUrlState(); + const { + isPaused, + isRunning, + pauseReasons, + enabled, + setEnabled, + manualRefresh, + lastUpdatedAt, + register, + unregister, + } = useAutoRefreshControl(); + const [searchFocused, setSearchFocused] = useState(false); + + useEffect(() => { + if (searchFocused) register('search-input'); + else unregister('search-input'); + return () => unregister('search-input'); + }, [searchFocused, register, unregister]); + + // Tick once a second so "updated Xs ago" stays fresh. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + const errorsOnlyActive = filters.hasError; + const indicatorText = isPaused + ? 'Paused' + : `Live · updated ${formatRelativeSeconds(lastUpdatedAt, now)}`; + const indicatorTitle = isPaused + ? pauseReasons.length > 0 + ? `Paused: ${pauseReasons.join(', ')}` + : 'Paused' + : `Auto-refreshing every 10s${ + lastUpdatedAt + ? ` · last update ${new Date(lastUpdatedAt).toLocaleTimeString()}` + : '' + }`; + + return ( +
+ {/* Row 1: title + back link */} +
+
+

+ System Logs +

+

+ View background jobs and system activity +

+
+ + + + + Back to Dashboard + +
+ + {/* Row 2: errors-only pill + live indicator + refresh + auto-toggle */} +
+ + +
+ + {indicatorText} +
+ + + + +
+ + {/* Row 3: search input */} +
+ + + + + + setSearchInput(e.target.value)} + onFocus={() => setSearchFocused(true)} + onBlur={() => setSearchFocused(false)} + placeholder="Search by job ID, error, event, book, or user…" + aria-label="Search logs" + className="w-full min-h-[44px] pl-9 pr-10 py-2.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm" + /> + {searchInput && ( + + )} +
+
+ ); +} diff --git a/src/app/admin/logs/components/UserTypeahead.tsx b/src/app/admin/logs/components/UserTypeahead.tsx new file mode 100644 index 0000000..571d9ea --- /dev/null +++ b/src/app/admin/logs/components/UserTypeahead.tsx @@ -0,0 +1,165 @@ +/** + * Component: Admin Logs — User Typeahead + * Documentation: documentation/admin-dashboard.md + * + * Combobox input + suggestion popover sourced from useUserSearch (fetch-once, + * SWR-cached, in-memory filter). Keyboard-navigable: ArrowUp/ArrowDown + + * Enter + Escape. Selection emits the user's id; the clear × button emits + * null so the filter resets. + * + * Pause-on-interact: registers `'logs-user-typeahead'` while the popover is open. + */ + +'use client'; + +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useRegisterPauseReason } from '../hooks/useAutoRefreshControl'; +import { useUserSearch, type UserSearchUser } from '../hooks/useUserSearch'; +import { INPUT_CLASS, LABEL_CLASS } from './filter-styles'; + +interface UserTypeaheadProps { + userId: string | null; + onChange: (id: string | null) => void; +} + +export default function UserTypeahead({ userId, onChange }: UserTypeaheadProps) { + const { filterByQuery, findUserById, isLoading } = useUserSearch(); + const selected = findUserById(userId); + const [query, setQuery] = useState(selected?.plexUsername ?? ''); + const [open, setOpen] = useState(false); + const [activeIdx, setActiveIdx] = useState(-1); + const containerRef = useRef(null); + const listboxId = useId(); + + useRegisterPauseReason('logs-user-typeahead', open); + + // Sync visible text if userId changes externally (e.g. chip dismissal). + useEffect(() => { + setQuery(selected?.plexUsername ?? ''); + }, [selected?.plexUsername]); + + const suggestions = useMemo( + () => (open ? filterByQuery(query) : []), + [open, query, filterByQuery] + ); + + const handleSelect = (user: UserSearchUser) => { + onChange(user.id); + setQuery(user.plexUsername); + setOpen(false); + setActiveIdx(-1); + }; + + const handleClear = () => { + onChange(null); + setQuery(''); + setActiveIdx(-1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setOpen(true); + setActiveIdx((idx) => Math.min(idx + 1, suggestions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx((idx) => Math.max(idx - 1, 0)); + } else if (e.key === 'Enter') { + if (activeIdx >= 0 && suggestions[activeIdx]) { + e.preventDefault(); + handleSelect(suggestions[activeIdx]); + } + } else if (e.key === 'Escape') { + setOpen(false); + setActiveIdx(-1); + } + }; + + // Close on outside click. + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + setActiveIdx(-1); + } + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, [open]); + + return ( +
+ +
+ { + setQuery(e.target.value); + setOpen(true); + setActiveIdx(-1); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className={`${INPUT_CLASS} pr-9`} + /> + {query && ( + + )} +
+ {open && suggestions.length > 0 && ( +
    + {suggestions.map((user, idx) => { + const isActive = idx === activeIdx; + return ( +
  • { + // onMouseDown so the input's blur doesn't fire first and close us. + e.preventDefault(); + handleSelect(user); + }} + onMouseEnter={() => setActiveIdx(idx)} + > + {user.plexUsername} + {user.role} +
  • + ); + })} +
+ )} + {open && !isLoading && suggestions.length === 0 && query.trim() !== '' && ( +
+ No users match “{query}” +
+ )} +
+ ); +} diff --git a/src/app/admin/logs/components/filter-styles.ts b/src/app/admin/logs/components/filter-styles.ts new file mode 100644 index 0000000..aacd81f --- /dev/null +++ b/src/app/admin/logs/components/filter-styles.ts @@ -0,0 +1,16 @@ +/** + * Component: Admin Logs — Shared Filter Control Styles + * Documentation: documentation/admin-dashboard.md + * + * One source of truth for the input / label class strings used by every + * picker in LogsFilters and its split-out siblings (DateRangePicker, + * UserTypeahead). Centralized so the five controls stay visually identical. + */ + +export const INPUT_CLASS = + 'w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 ' + + 'border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 ' + + 'focus:outline-none text-sm min-h-[44px]'; + +export const LABEL_CLASS = + 'block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5'; diff --git a/src/app/admin/logs/hooks/useAutoRefreshControl.ts b/src/app/admin/logs/hooks/useAutoRefreshControl.ts new file mode 100644 index 0000000..501f31f --- /dev/null +++ b/src/app/admin/logs/hooks/useAutoRefreshControl.ts @@ -0,0 +1,210 @@ +/** + * Component: useAutoRefreshControl Hook + * Documentation: documentation/admin-dashboard.md + * + * Pause-on-interact registry shared across the logs page: + * - Components call register(reason) on focus/open and unregister(reason) on blur/close. + * - Non-empty reasons → paused (SWR refreshInterval=0). Empty → 10s polling. + * - 250ms debounce on pause-EXIT prevents "Paused" indicator flicker when a + * dropdown is opened-and-immediately-closed. + * - User-controlled off toggle persists to sessionStorage (per-tab). + * - manualRefresh() is provided to fire an out-of-band refetch. + * + * Singleton pattern: the page calls `useAutoRefreshControlProvider()` to OWN + * the state, child components call `useAutoRefreshControl()` to CONSUME it + * via the shared context. + */ + +'use client'; + +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + createElement, +} from 'react'; + +const REFRESH_INTERVAL_MS = 10_000; +const PAUSE_EXIT_DEBOUNCE_MS = 250; +const STORAGE_KEY = 'admin-logs:auto-refresh-enabled'; + +export interface AutoRefreshControl { + /** True when auto-refresh is currently effectively running (not paused, user-enabled). */ + isRunning: boolean; + /** True when paused for any reason (interaction OR user toggle off). */ + isPaused: boolean; + /** Stable list of human-readable pause reasons for the tooltip. */ + pauseReasons: string[]; + /** User toggle — when false, auto-refresh is forced off regardless of interactions. */ + enabled: boolean; + setEnabled: (next: boolean) => void; + /** SWR refreshInterval value to pass: REFRESH_INTERVAL_MS when running, 0 when paused. */ + effectiveInterval: number; + /** Register a pause reason (idempotent by reason key). */ + register: (reason: string) => void; + /** Unregister a pause reason (idempotent). */ + unregister: (reason: string) => void; + /** Trigger a one-shot refresh now (consumer wires this to SWR `mutate`). */ + manualRefresh: () => void; + /** Setter the consumer (page.tsx) uses to wire the mutate fn into the registry. */ + setMutate: (fn: (() => Promise | void) | null) => void; + /** Setter the consumer uses to broadcast "we just got fresh data at ". */ + setLastUpdatedAt: (ts: number) => void; + /** Timestamp of last successful refresh (ms since epoch); 0 if never. */ + lastUpdatedAt: number; +} + +const AutoRefreshContext = createContext(null); + +// --------------------------------------------------------------------------- +// Provider — owns state; rendered by page.tsx so all children share it. +// --------------------------------------------------------------------------- +export function AutoRefreshControlProvider({ children }: { children: ReactNode }) { + const value = useAutoRefreshControlImpl(); + return createElement(AutoRefreshContext.Provider, { value }, children); +} + +// --------------------------------------------------------------------------- +// Consumer hook — used by every component that wants to read state OR +// register/unregister pause reasons. +// --------------------------------------------------------------------------- +export function useAutoRefreshControl(): AutoRefreshControl { + const ctx = useContext(AutoRefreshContext); + if (!ctx) { + throw new Error( + 'useAutoRefreshControl must be used inside ' + ); + } + return ctx; +} + +// --------------------------------------------------------------------------- +// Implementation — only called once by the provider. +// --------------------------------------------------------------------------- +function useAutoRefreshControlImpl(): AutoRefreshControl { + // User toggle, hydrated from sessionStorage post-mount (SSR-safe). + const [enabled, setEnabledState] = useState(true); + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const stored = window.sessionStorage.getItem(STORAGE_KEY); + if (stored === '0') setEnabledState(false); + } catch { + // sessionStorage can throw in private mode — fall through with default. + } + }, []); + + const setEnabled = useCallback((next: boolean) => { + setEnabledState(next); + if (typeof window === 'undefined') return; + try { + window.sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0'); + } catch { + // ignore + } + }, []); + + // Pause reasons — a Set kept in a ref so register/unregister don't churn + // React state on every effect mount/unmount. We mirror SIZE/CONTENT into a + // version counter + a debounced visible-reasons state for rendering. + const reasonsRef = useRef>(new Set()); + const [visibleReasons, setVisibleReasons] = useState([]); + const exitDebounceRef = useRef | null>(null); + + const flushVisible = useCallback(() => { + setVisibleReasons(Array.from(reasonsRef.current).sort()); + }, []); + + const register = useCallback( + (reason: string) => { + if (reasonsRef.current.has(reason)) return; + reasonsRef.current.add(reason); + // Entry → reflect immediately (no flicker concern when ADDING a reason). + if (exitDebounceRef.current) { + clearTimeout(exitDebounceRef.current); + exitDebounceRef.current = null; + } + flushVisible(); + }, + [flushVisible] + ); + + const unregister = useCallback( + (reason: string) => { + if (!reasonsRef.current.has(reason)) return; + reasonsRef.current.delete(reason); + // Exit → debounce so brief blips (dropdown opened-then-closed) don't flash. + if (exitDebounceRef.current) clearTimeout(exitDebounceRef.current); + exitDebounceRef.current = setTimeout(() => { + exitDebounceRef.current = null; + flushVisible(); + }, PAUSE_EXIT_DEBOUNCE_MS); + }, + [flushVisible] + ); + + // Clean up any pending debounce on unmount. + useEffect(() => { + return () => { + if (exitDebounceRef.current) clearTimeout(exitDebounceRef.current); + }; + }, []); + + // Manual refresh — page.tsx wires SWR's `mutate` in via setMutate. + const mutateRef = useRef<(() => Promise | void) | null>(null); + const setMutate = useCallback((fn: (() => Promise | void) | null) => { + mutateRef.current = fn; + }, []); + const manualRefresh = useCallback(() => { + const fn = mutateRef.current; + if (fn) fn(); + }, []); + + // lastUpdatedAt — page.tsx broadcasts when SWR data lands. + const [lastUpdatedAt, setLastUpdatedAt] = useState(0); + + const isInteractionPaused = visibleReasons.length > 0; + const isPaused = !enabled || isInteractionPaused; + const isRunning = !isPaused; + const effectiveInterval = isRunning ? REFRESH_INTERVAL_MS : 0; + + const pauseReasons = useMemo(() => { + const out: string[] = []; + if (!enabled) out.push('Auto-refresh off'); + out.push(...visibleReasons); + return out; + }, [enabled, visibleReasons]); + + return { + isRunning, + isPaused, + pauseReasons, + enabled, + setEnabled, + effectiveInterval, + register, + unregister, + manualRefresh, + setMutate, + setLastUpdatedAt, + lastUpdatedAt, + }; +} + +// --------------------------------------------------------------------------- +// Convenience: useRegisterPauseReason — fire-and-forget register/unregister +// based on a boolean flag (used by components that want declarative usage). +// --------------------------------------------------------------------------- +export function useRegisterPauseReason(reason: string, active: boolean): void { + const { register, unregister } = useAutoRefreshControl(); + useEffect(() => { + if (active) register(reason); + else unregister(reason); + return () => unregister(reason); + }, [active, reason, register, unregister]); +} diff --git a/src/app/admin/logs/hooks/useLogsUrlState.ts b/src/app/admin/logs/hooks/useLogsUrlState.ts new file mode 100644 index 0000000..3b80d41 --- /dev/null +++ b/src/app/admin/logs/hooks/useLogsUrlState.ts @@ -0,0 +1,278 @@ +/** + * Component: useLogsUrlState Hook + * Documentation: documentation/admin-dashboard.md + * + * URL ↔ typed filter state. URL is the single source of truth. + * - reads URL params on every render (validated; invalid values silently dropped) + * - writes URL via router.replace (no history pollution) + * - search input writes are debounced (300ms) so typing feels instant + * - any non-page filter change resets page to 1 + */ + +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; +import { JOB_TYPE_LABELS } from '@/lib/constants/job-labels'; +import { DEFAULT_DATE_PRESET_ID, presetToRange } from '@/lib/constants/log-filters'; +import { + DEFAULT_FILTER_STATE, + DEFAULT_LIMIT, + DEFAULT_PAGE, + LOG_PARAMS, + LogsFilterState, + VALID_LIMITS, + VALID_STATUSES, + ValidLimit, +} from '../types'; + +const SEARCH_DEBOUNCE_MS = 300; + +// --------------------------------------------------------------------------- +// URL → typed state (silently drops invalid values) +// --------------------------------------------------------------------------- +function parseFromUrl(params: URLSearchParams): LogsFilterState { + const status = params.get(LOG_PARAMS.status); + const type = params.get(LOG_PARAMS.type); + const dateFrom = params.get(LOG_PARAMS.dateFrom); + const dateTo = params.get(LOG_PARAMS.dateTo); + const hasError = params.get(LOG_PARAMS.hasError); + const userId = params.get(LOG_PARAMS.userId); + const audiobookQuery = params.get(LOG_PARAMS.audiobookQuery); + const search = params.get(LOG_PARAMS.search); + const pageRaw = params.get(LOG_PARAMS.page); + const limitRaw = params.get(LOG_PARAMS.limit); + + // Page: positive int or default + let page = DEFAULT_PAGE; + if (pageRaw) { + const parsed = Number.parseInt(pageRaw, 10); + if (Number.isFinite(parsed) && parsed >= 1) page = parsed; + } + + // Limit: must be in VALID_LIMITS or default + let limit: ValidLimit = DEFAULT_LIMIT; + if (limitRaw) { + const parsed = Number.parseInt(limitRaw, 10); + if ((VALID_LIMITS as readonly number[]).includes(parsed)) { + limit = parsed as ValidLimit; + } + } + + // Status: must be in VALID_STATUSES or default to 'all' + const validStatus = + status && (VALID_STATUSES as readonly string[]).includes(status) ? status : 'all'; + + // Type: must be in JOB_TYPE_LABELS or default to 'all' + const validType = type && (type === 'all' || type in JOB_TYPE_LABELS) ? type : 'all'; + + // Date: must parse as a valid date or null + const validDateFrom = isValidIsoDate(dateFrom) ? dateFrom : null; + const validDateTo = isValidIsoDate(dateTo) ? dateTo : null; + + return { + search: search ?? '', + status: validStatus, + type: validType, + dateFrom: validDateFrom, + dateTo: validDateTo, + hasError: hasError === '1' || hasError === 'true', + userId: userId && userId.length > 0 ? userId : null, + audiobookQuery: audiobookQuery ?? '', + page, + limit, + }; +} + +function isValidIsoDate(value: string | null): value is string { + if (!value) return false; + const d = new Date(value); + return !Number.isNaN(d.getTime()); +} + +// --------------------------------------------------------------------------- +// typed state → URLSearchParams (omits defaults so URLs stay short) +// --------------------------------------------------------------------------- +function serializeToUrl(state: LogsFilterState): URLSearchParams { + const params = new URLSearchParams(); + if (state.page !== DEFAULT_PAGE) params.set(LOG_PARAMS.page, String(state.page)); + if (state.limit !== DEFAULT_LIMIT) params.set(LOG_PARAMS.limit, String(state.limit)); + if (state.status && state.status !== 'all') params.set(LOG_PARAMS.status, state.status); + if (state.type && state.type !== 'all') params.set(LOG_PARAMS.type, state.type); + if (state.search) params.set(LOG_PARAMS.search, state.search); + if (state.dateFrom) params.set(LOG_PARAMS.dateFrom, state.dateFrom); + if (state.dateTo) params.set(LOG_PARAMS.dateTo, state.dateTo); + if (state.hasError) params.set(LOG_PARAMS.hasError, '1'); + if (state.userId) params.set(LOG_PARAMS.userId, state.userId); + if (state.audiobookQuery) params.set(LOG_PARAMS.audiobookQuery, state.audiobookQuery); + return params; +} + +// --------------------------------------------------------------------------- +// Public hook +// --------------------------------------------------------------------------- +export interface UseLogsUrlStateResult { + filters: LogsFilterState; + /** Merge partial state; any non-page change resets page to 1. */ + setFilters: (partial: Partial) => void; + /** Set the search string; debounced URL write (300ms). UI value is immediate. */ + setSearchInput: (value: string) => void; + /** The non-debounced search value (what the user is currently typing). */ + searchInput: string; + /** Reset to DEFAULT_FILTER_STATE. */ + clearAll: () => void; + /** Remove a single filter (reset to its default). Resets page to 1. */ + removeFilter: (key: keyof LogsFilterState) => void; + /** + * True iff the current `filters.dateFrom`/`dateTo` come from the Zach #1 + * hydrate-time "Last 7 days" default (URL had neither bound and user hasn't + * touched anything yet). Page uses this to pick "fresh" vs "filters-too-tight" + * empty-state copy — the hydrate default shouldn't be treated as a + * user-applied filter. + */ + usingHydrateDateDefault: boolean; +} + +export function useLogsUrlState(): UseLogsUrlStateResult { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // Zach Resolution #1: on FIRST mount, if the URL has neither dateFrom nor + // dateTo, apply "Last 7 days" as the active range — but do NOT write those + // values to the URL (keeps shareable links clean). The default lives only + // in this hook's memory; the user's NEXT action (click All-time, change any + // other filter, etc.) writes the URL with the then-effective values. + // + // Mechanism: a one-shot hydrate range stored in a ref. It's used to backfill + // dates ONLY while: + // (a) the user hasn't taken an action that touched the date filter, AND + // (b) the URL still has neither dateFrom nor dateTo. + // Either condition flipping false retires the hydrate default forever. + const hydrateRangeRef = useRef<{ dateFrom: string | null; dateTo: string | null } | null>( + null + ); + const dateInteractedRef = useRef(false); + if (hydrateRangeRef.current === null && !dateInteractedRef.current) { + hydrateRangeRef.current = presetToRange(DEFAULT_DATE_PRESET_ID); + } + + // Parse from URL on every render — URL is the source of truth. + // Then layer the hydrate default on top when applicable. + const { filters, usingHydrateDateDefault } = useMemo(() => { + const parsed = parseFromUrl(new URLSearchParams(searchParams?.toString() ?? '')); + const hydrate = hydrateRangeRef.current; + if ( + hydrate && + !dateInteractedRef.current && + parsed.dateFrom === null && + parsed.dateTo === null + ) { + return { + filters: { + ...parsed, + dateFrom: hydrate.dateFrom, + dateTo: hydrate.dateTo, + }, + usingHydrateDateDefault: true, + }; + } + return { filters: parsed, usingHydrateDateDefault: false }; + }, [searchParams]); + + // Local "search input" mirrors URL but updates immediately for typing feel. + const [searchInput, setSearchInputState] = useState(filters.search); + const searchDebounceRef = useRef | null>(null); + + // Re-sync local search input if the URL search changes externally + // (e.g. user clicks the search chip's × — chip dismissal sets URL, + // we need to mirror that back to the input). + useEffect(() => { + setSearchInputState(filters.search); + // We only want to sync from URL → input when the URL changes — + // not when the user is mid-type. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters.search]); + + const writeUrl = useCallback( + (nextState: LogsFilterState) => { + // Any user-driven URL write retires the hydrate default. The just-written + // URL is now authoritative — either it carries the hydrate dates (if the + // user touched something else and the merge preserved them) or it + // doesn't (if the user explicitly cleared them). Either way, subsequent + // renders must trust the URL, not re-apply the default. + dateInteractedRef.current = true; + const qs = serializeToUrl(nextState).toString(); + const url = qs ? `${pathname}?${qs}` : pathname; + router.replace(url, { scroll: false }); + }, + [pathname, router] + ); + + const setFilters = useCallback( + (partial: Partial) => { + // Any non-page change resets page to 1. + const isOnlyPageChange = + Object.keys(partial).length === 1 && Object.prototype.hasOwnProperty.call(partial, 'page'); + const next: LogsFilterState = { + ...filters, + ...partial, + page: isOnlyPageChange ? (partial.page ?? filters.page) : DEFAULT_PAGE, + }; + writeUrl(next); + }, + [filters, writeUrl] + ); + + const setSearchInput = useCallback( + (value: string) => { + setSearchInputState(value); + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + const next: LogsFilterState = { + ...filters, + search: value, + page: DEFAULT_PAGE, + }; + writeUrl(next); + }, SEARCH_DEBOUNCE_MS); + }, + [filters, writeUrl] + ); + + // Clear any pending debounce on unmount. + useEffect(() => { + return () => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + }; + }, []); + + const clearAll = useCallback(() => { + writeUrl(DEFAULT_FILTER_STATE); + setSearchInputState(''); + }, [writeUrl]); + + const removeFilter = useCallback( + (key: keyof LogsFilterState) => { + const defaultValue = DEFAULT_FILTER_STATE[key]; + const next: LogsFilterState = { + ...filters, + [key]: defaultValue, + page: DEFAULT_PAGE, + } as LogsFilterState; + writeUrl(next); + if (key === 'search') setSearchInputState(''); + }, + [filters, writeUrl] + ); + + return { + filters, + setFilters, + setSearchInput, + searchInput, + clearAll, + removeFilter, + usingHydrateDateDefault, + }; +} diff --git a/src/app/admin/logs/hooks/useUserSearch.ts b/src/app/admin/logs/hooks/useUserSearch.ts new file mode 100644 index 0000000..b0ca23a --- /dev/null +++ b/src/app/admin/logs/hooks/useUserSearch.ts @@ -0,0 +1,88 @@ +/** + * Component: useUserSearch Hook (admin logs user typeahead) + * Documentation: documentation/admin-dashboard.md + * + * Fetch-once-and-cache user directory from /api/admin/users for the user + * typeahead in LogsFilters. SWR caches the response for the session so every + * keystroke filters in-memory — no per-keystroke network round-trip. + * + * Assumes installs have <500 users (Zach Resolution #3 — fine for self-hosted). + */ + +'use client'; + +import { useCallback, useMemo } from 'react'; +import useSWR from 'swr'; +import { authenticatedFetcher } from '@/lib/utils/api'; + +const USERS_URL = '/api/admin/users'; +const MAX_SUGGESTIONS = 10; +// One-time-per-session cache: dedupe identical fetches for an hour. +const DEDUPING_INTERVAL_MS = 60 * 60 * 1000; + +export interface UserSearchUser { + id: string; + plexUsername: string; + role: string; +} + +interface UsersApiResponse { + users: UserSearchUser[]; +} + +export interface UseUserSearchResult { + users: UserSearchUser[]; + filterByQuery: (q: string) => UserSearchUser[]; + /** Resolve a user by id — handy for chip label rendering. */ + findUserById: (id: string | null | undefined) => UserSearchUser | undefined; + isLoading: boolean; + error: Error | null; +} + +export function useUserSearch(): UseUserSearchResult { + const { data, error, isLoading } = useSWR( + USERS_URL, + authenticatedFetcher, + { + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: false, + dedupingInterval: DEDUPING_INTERVAL_MS, + } + ); + + const users = useMemo(() => data?.users ?? [], [data]); + + const filterByQuery = useCallback( + (q: string): UserSearchUser[] => { + if (users.length === 0) return []; + const trimmed = q.trim().toLowerCase(); + if (!trimmed) return users.slice(0, MAX_SUGGESTIONS); + const out: UserSearchUser[] = []; + for (const u of users) { + if (u.plexUsername.toLowerCase().includes(trimmed)) { + out.push(u); + if (out.length >= MAX_SUGGESTIONS) break; + } + } + return out; + }, + [users] + ); + + const findUserById = useCallback( + (id: string | null | undefined): UserSearchUser | undefined => { + if (!id) return undefined; + return users.find((u) => u.id === id); + }, + [users] + ); + + return { + users, + filterByQuery, + findUserById, + isLoading, + error: (error as Error | null) ?? null, + }; +} diff --git a/src/app/admin/logs/page.tsx b/src/app/admin/logs/page.tsx index 5ea0b1c..7b28413 100644 --- a/src/app/admin/logs/page.tsx +++ b/src/app/admin/logs/page.tsx @@ -1,504 +1,251 @@ /** * Component: Admin System Logs Page * Documentation: documentation/admin-dashboard.md + * + * Thin orchestrator: reads URL via useLogsUrlState, owns SWR + pause registry, + * composes sub-components. Empty-state copy as a pure function of + * { totalResults, hasActiveFilters, hasActiveSearch }. */ 'use client'; -import { useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; import useSWR from 'swr'; -import Link from 'next/link'; +import { ToastProvider } from '@/components/ui/Toast'; import { authenticatedFetcher } from '@/lib/utils/api'; +import { + buildLogsApiKey, + computeEmptyState, + hasActiveFilters, + hasActiveSearch, + Log, + LogsData, + ValidLimit, +} from './types'; +import { useLogsUrlState } from './hooks/useLogsUrlState'; +import { + AutoRefreshControlProvider, + useAutoRefreshControl, +} from './hooks/useAutoRefreshControl'; +import { LogsToolbar } from './components/LogsToolbar'; +import { LogSkeleton } from './components/LogSkeleton'; +import { LogsPagination } from './components/LogsPagination'; +import { LogRow } from './components/LogRow'; +import LogsFilters from './components/LogsFilters'; +import ActiveFilterChips from './components/ActiveFilterChips'; -interface JobEvent { - id: string; - level: string; - context: string; - message: string; - metadata: any; - createdAt: string; -} - -interface Log { - id: string; - bullJobId: string | null; - type: string; - status: string; - priority: number; - attempts: number; - maxAttempts: number; - errorMessage: string | null; - startedAt: string | null; - completedAt: string | null; - createdAt: string; - updatedAt: string; - result: any; - events: JobEvent[]; - request: { - id: string; - audiobook: { - title: string; - author: string; - } | null; - user: { - plexUsername: string; - }; - } | null; -} - -interface LogsData { - logs: Log[]; - pagination: { - page: number; - limit: number; - total: number; - totalPages: number; - }; -} - -function StatusBadge({ status }: { status: string }) { - const config: Record = { - completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' }, - failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' }, - active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' }, - pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' }, - delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' }, - stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' }, - }; - const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' }; - +function EmptyState({ + kind, + onClearFilters, + onClearSearch, + searchValue, +}: { + kind: 'fresh' | 'filters-too-tight' | 'search-no-match'; + onClearFilters: () => void; + onClearSearch: () => void; + searchValue: string; +}) { + if (kind === 'fresh') { + return ( +
+

+ No background jobs have run yet. +

+

+ New jobs will appear here as they start. +

+
+ ); + } + if (kind === 'search-no-match') { + return ( +
+

+ No matches for “{searchValue}”. +

+ +
+ ); + } return ( - - - {status.charAt(0).toUpperCase() + status.slice(1)} - - ); -} - -function LogDetails({ log }: { log: Log }) { - return ( -
- {log.bullJobId && ( -
- Bull Job ID: - {log.bullJobId} -
- )} - - {log.events.length > 0 && ( -
-

- Event Log -

-
- {log.events.map((event) => { - const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0]; - const levelColor = event.level === 'error' - ? 'text-red-400' - : event.level === 'warn' - ? 'text-amber-400' - : 'text-emerald-400'; - - return ( -
- [{event.context}] - {' '} - {event.message} - {timestamp} - {event.metadata && Object.keys(event.metadata).length > 0 && ( -
-                      {JSON.stringify(event.metadata, null, 2)}
-                    
- )} -
- ); - })} -
-
- )} - - {log.result && Object.keys(log.result).length > 0 && ( -
-

- Job Result -

-
-            {JSON.stringify(log.result, null, 2)}
-          
-
- )} - - {log.errorMessage && ( -
-

- Error -

-
- {log.errorMessage} -
-
- )} +
+

+ No logs match your current filters. +

+
); } -function formatDuration(startedAt: string | null, completedAt: string | null) { - if (!startedAt) return 'N/A'; - if (!completedAt) return 'Running…'; - const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime(); - const seconds = Math.floor(durationMs / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - if (hours > 0) return `${hours}h ${minutes % 60}m`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s`; - return `${seconds}s`; -} +function AdminLogsPageContent() { + const { filters, setFilters, clearAll, usingHydrateDateDefault } = useLogsUrlState(); + const { effectiveInterval, setMutate, setLastUpdatedAt } = useAutoRefreshControl(); -function formatType(type: string) { - return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); -} + const key = buildLogsApiKey(filters); -function formatDateShort(dateStr: string) { - const d = new Date(dateStr); - const now = new Date(); - const isToday = d.toDateString() === now.toDateString(); - if (isToday) { - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - } - return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + - d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} + // Track previous key to distinguish initial-load / filter-change skeleton + // from auto-refresh (which preserves rows). + const previousKeyRef = useRef(key); + const [keyChanging, setKeyChanging] = useState(false); -export default function AdminLogsPage() { - const [page, setPage] = useState(1); - const [statusFilter, setStatusFilter] = useState('all'); - const [typeFilter, setTypeFilter] = useState('all'); - const [expandedLog, setExpandedLog] = useState(null); + const { data, error, mutate } = useSWR(key, authenticatedFetcher, { + refreshInterval: effectiveInterval, + keepPreviousData: true, + }); - const { data, error } = useSWR( - `/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`, - authenticatedFetcher, - { refreshInterval: 10000 } - ); + // Wire SWR's mutate into the auto-refresh control so "Refresh now" works. + useEffect(() => { + setMutate(() => mutate()); + return () => setMutate(null); + }, [mutate, setMutate]); - const isLoading = !data && !error; + // Broadcast a "fresh data" timestamp when SWR data lands. + useEffect(() => { + if (data) setLastUpdatedAt(Date.now()); + }, [data, setLastUpdatedAt]); - if (isLoading) { - return ( -
-
-
-
-
- ); - } + // Skeleton-vs-rows decision: + // - !data → initial skeleton. + // - key changed AND no data for the new key yet → skeleton on transition. + // SWR's `keepPreviousData` makes data === previous response until the new + // one lands, so we explicitly track key changes. + useEffect(() => { + if (previousKeyRef.current !== key) { + previousKeyRef.current = key; + setKeyChanging(true); + } + }, [key]); - if (error) { - return ( -
-
-
-

Error Loading Logs

-

- {error?.message || 'Failed to load system logs'} -

-
-
-
- ); - } + useEffect(() => { + if (keyChanging && data) setKeyChanging(false); + }, [data, keyChanging]); - const logs = data?.logs || []; - const pagination = data?.pagination; - const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0); + const showSkeleton = !data || keyChanging; + const logs: Log[] = data?.logs ?? []; + const pagination = data?.pagination ?? { page: filters.page, limit: filters.limit, total: 0, totalPages: 1 }; + + // When the hydrate-time "Last 7 days" default is in effect (the user hasn't + // explicitly chosen a date range), don't count it as a user-applied filter + // for empty-state branching — show the "fresh" message, not "filters too + // tight". hasActiveFilters() is otherwise the canonical check. + const filtersForEmptyState = usingHydrateDateDefault + ? { ...filters, dateFrom: null, dateTo: null } + : filters; + const emptyKind = computeEmptyState({ + total: pagination.total, + hasFilters: hasActiveFilters(filtersForEmptyState), + hasSearch: hasActiveSearch(filters), + }); return (
+ - {/* Header — stacks on mobile, row on sm+ */} -
-
-
-

- System Logs -

-

- View background jobs and system activity -

-
- - - - - Back to Dashboard - -
-
+ {/* Filter dropdowns + chip strip — owned by ben-filters, rendered here. */} + + - {/* Filters — full-width stacked on mobile */} -
-
- - -
-
- - -
-
- - {/* Mobile card list — hidden on sm+ */} -
- {logs.map((log) => ( -
- {/* Card header */} -
-
-
- {formatType(log.type)} -
- -
- - {/* Related item */} - {log.request?.audiobook ? ( -
-
- {log.request.audiobook.title} -
-
- by {log.request.audiobook.author} · {log.request.user.plexUsername} -
-
- ) : ( -
System job
- )} - - {/* Meta row */} -
- {formatDateShort(log.createdAt)} - Duration: {formatDuration(log.startedAt, log.completedAt)} - Attempts: {log.attempts}/{log.maxAttempts} -
-
- - {/* Expandable details */} - {hasDetails(log) && ( - <> - - {expandedLog === log.id && ( -
- -
- )} - - )} -
- ))} - {logs.length === 0 && ( -
-

No logs found

-
- )} -
- - {/* Desktop table — hidden on mobile */} -
-
- - - - - - - - - - - - - - {logs.map((log) => ( - <> - - - - - - - - - - {expandedLog === log.id && ( - - - - )} - - ))} - -
- Time - - Type - - Status - - Related Item - - Duration - - Attempts - - Actions -
- {new Date(log.createdAt).toLocaleString()} - -
- {formatType(log.type)} -
-
- - - {log.request?.audiobook ? ( -
-
- {log.request.audiobook.title} -
-
- by {log.request.audiobook.author} -
-
- User: {log.request.user.plexUsername} -
-
- ) : ( - System job - )} -
- {formatDuration(log.startedAt, log.completedAt)} - - {log.attempts}/{log.maxAttempts} - - {hasDetails(log) && ( - - )} -
- -
-
- - {logs.length === 0 && ( -
-

No logs found

-
- )} -
- - {/* Pagination */} - {pagination && pagination.totalPages > 1 && ( -
-
- Page {pagination.page} of {pagination.totalPages} - ({pagination.total} total logs) -
-
- - -
+ {error && ( +
+

+ Error Loading Logs +

+

+ {error?.message || 'Failed to load system logs'} +

)} - {/* Info Box */} -
-

- About System Logs -

-
    -
  • • Logs are automatically refreshed every 10 seconds
  • -
  • • Tap "Show Details" to view event logs, job results, and errors
  • -
  • • Event logs show all internal operations with timestamps
  • -
  • • Jobs are retried automatically based on their max attempts setting
  • -
  • • Use filters to find specific job types or statuses
  • -
-
+ {showSkeleton ? ( + + ) : emptyKind ? ( + setFilters({ search: '' })} + searchValue={filters.search} + /> + ) : ( + <> + {/* Mobile cards */} +
+ {logs.map((log) => ( + + ))} +
+ + {/* Desktop table */} +
+
+ + + + + + + + + + + + + + {logs.map((log) => ( + + ))} + +
+ Time + + Type + + Status + + Related Item + + Duration + + Attempts + + Actions +
+
+
+ + setFilters({ page })} + onLimitChange={(limit: ValidLimit) => setFilters({ limit })} + /> + + )}
); } + +export default function AdminLogsPage() { + return ( + + + + + + + + ); +} diff --git a/src/app/admin/logs/types.ts b/src/app/admin/logs/types.ts new file mode 100644 index 0000000..fb99651 --- /dev/null +++ b/src/app/admin/logs/types.ts @@ -0,0 +1,200 @@ +/** + * Component: Admin Logs — Shared Types & Filter Contract + * Documentation: documentation/admin-dashboard.md + * + * Stage 0 contract: filter state shape + URL/API param names + SWR key helper. + * URL param names === API param names — no translation layer. + * `buildLogsApiKey` is the SWR key/test seam (frontend only — backend tests + * assert against parsed URLSearchParams / where-clause). + */ + +// --------------------------------------------------------------------------- +// Param names — used as BOTH URL search params AND API query string params. +// --------------------------------------------------------------------------- +export const LOG_PARAMS = { + search: 'search', + status: 'status', + type: 'type', + dateFrom: 'dateFrom', + dateTo: 'dateTo', + hasError: 'hasError', + userId: 'userId', + audiobookQuery: 'audiobookQuery', + page: 'page', + limit: 'limit', +} as const; + +export type LogParamKey = keyof typeof LOG_PARAMS; + +// --------------------------------------------------------------------------- +// Valid value sets +// --------------------------------------------------------------------------- +export const VALID_LIMITS = [25, 50, 100] as const; +export type ValidLimit = typeof VALID_LIMITS[number]; + +export const VALID_STATUSES = [ + 'all', + 'pending', + 'active', + 'completed', + 'failed', + 'delayed', + 'stuck', +] as const; +export type LogStatus = typeof VALID_STATUSES[number]; + +export const DEFAULT_LIMIT: ValidLimit = 50; +export const DEFAULT_PAGE = 1; + +// --------------------------------------------------------------------------- +// Filter state — single source of truth, both URL hydration target and API input +// --------------------------------------------------------------------------- +export interface LogsFilterState { + search: string; // '' = no search + status: string; // 'all' default; validated against VALID_STATUSES on read + type: string; // 'all' default; validated against JOB_TYPE_LABELS keys on read + dateFrom: string | null; // ISO UTC; null = no lower bound + dateTo: string | null; // ISO UTC; null = no upper bound + hasError: boolean; // false default + userId: string | null; // null = any user + audiobookQuery: string; // '' = no book filter + page: number; // 1-based + limit: ValidLimit; // 25 | 50 | 100 +} + +export const DEFAULT_FILTER_STATE: LogsFilterState = { + search: '', + status: 'all', + type: 'all', + dateFrom: null, + dateTo: null, + hasError: false, + userId: null, + audiobookQuery: '', + page: DEFAULT_PAGE, + limit: DEFAULT_LIMIT, +}; + +// --------------------------------------------------------------------------- +// Log data types — match the existing API response shape +// (which mirrors prisma Job + JobEvent + Request joins) +// --------------------------------------------------------------------------- +export interface JobEvent { + id: string; + level: 'info' | 'warn' | 'error' | string; + context: string; + message: string; + metadata: Record | null; + createdAt: string; +} + +export interface LogRequestRelation { + id: string; + audiobook: { + title: string; + author: string; + } | null; + user: { + plexUsername: string; + }; +} + +export interface Log { + id: string; + bullJobId: string | null; + type: string; + status: string; + priority: number; + attempts: number; + maxAttempts: number; + errorMessage: string | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; + updatedAt: string; + result: Record | null; + events: JobEvent[]; + request: LogRequestRelation | null; +} + +export interface LogsPagination { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface LogsData { + logs: Log[]; + pagination: LogsPagination; +} + +// --------------------------------------------------------------------------- +// API key / URL builder — single source of truth shared by SWR and tests. +// Omits params at their default values so the key stays stable & short. +// --------------------------------------------------------------------------- +export function buildLogsApiKey(state: LogsFilterState): string { + const params = new URLSearchParams(); + + // page + limit are always present so SWR cache keys are deterministic + params.set(LOG_PARAMS.page, String(state.page)); + params.set(LOG_PARAMS.limit, String(state.limit)); + + if (state.status && state.status !== 'all') params.set(LOG_PARAMS.status, state.status); + if (state.type && state.type !== 'all') params.set(LOG_PARAMS.type, state.type); + if (state.search) params.set(LOG_PARAMS.search, state.search); + if (state.dateFrom) params.set(LOG_PARAMS.dateFrom, state.dateFrom); + if (state.dateTo) params.set(LOG_PARAMS.dateTo, state.dateTo); + if (state.hasError) params.set(LOG_PARAMS.hasError, '1'); + if (state.userId) params.set(LOG_PARAMS.userId, state.userId); + if (state.audiobookQuery) params.set(LOG_PARAMS.audiobookQuery, state.audiobookQuery); + + return `/api/admin/logs?${params.toString()}`; +} + +// --------------------------------------------------------------------------- +// Detail-panel predicate — does this log have anything worth disclosing? +// --------------------------------------------------------------------------- +export function logHasDetails(log: Log): boolean { + return ( + log.events.length > 0 || + !!log.errorMessage || + !!log.bullJobId || + (log.result != null && Object.keys(log.result).length > 0) + ); +} + +// --------------------------------------------------------------------------- +// Active-filter detection — drives empty-state copy + "Clear all" affordance +// --------------------------------------------------------------------------- +export function hasActiveFilters(state: LogsFilterState): boolean { + return ( + state.status !== 'all' || + state.type !== 'all' || + state.dateFrom !== null || + state.dateTo !== null || + state.hasError || + state.userId !== null || + state.audiobookQuery !== '' + ); +} + +export function hasActiveSearch(state: LogsFilterState): boolean { + return state.search !== ''; +} + +export type EmptyStateKind = + | 'fresh' // no rows, no filters, no search + | 'filters-too-tight' // no rows, filters active, no search + | 'search-no-match'; // no rows, search active (filters may or may not be active) + +export function computeEmptyState(args: { + total: number; + hasFilters: boolean; + hasSearch: boolean; +}): EmptyStateKind | null { + if (args.total > 0) return null; + if (args.hasSearch) return 'search-no-match'; + if (args.hasFilters) return 'filters-too-tight'; + return 'fresh'; +} diff --git a/src/app/api/admin/logs/route.ts b/src/app/api/admin/logs/route.ts index 0578219..9ad1a55 100644 --- a/src/app/api/admin/logs/route.ts +++ b/src/app/api/admin/logs/route.ts @@ -10,27 +10,147 @@ import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Admin.Logs'); +const VALID_LIMITS = [25, 50, 100] as const; +const DEFAULT_LIMIT = 50; +const ERROR_STATUSES = ['failed', 'stuck'] as const; + +export interface LogsWhereParams { + status?: string | null; + type?: string | null; + search?: string | null; + dateFrom?: string | null; + dateTo?: string | null; + hasError?: string | null; + userId?: string | null; + audiobookQuery?: string | null; +} + +function parseLimit(raw: string | null): number { + const n = Number(raw); + return (VALID_LIMITS as readonly number[]).includes(n) ? n : DEFAULT_LIMIT; +} + +function parsePage(raw: string | null): number { + const n = parseInt(raw ?? '1', 10); + return Number.isFinite(n) && n >= 1 ? n : 1; +} + +function isTruthy(raw: string | null | undefined): boolean { + if (!raw) return false; + const v = raw.toLowerCase(); + return v === 'true' || v === '1'; +} + +function parseDate(raw: string | null | undefined): Date | null { + if (!raw) return null; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? null : d; +} + +function trim(raw: string | null | undefined): string | null { + if (!raw) return null; + const t = raw.trim(); + return t.length > 0 ? t : null; +} + +export function buildLogsWhere(params: LogsWhereParams): Record { + const where: Record = {}; + + const status = params.status ?? 'all'; + if (status !== 'all' && status !== '') { + where.status = status; + } + + const type = params.type ?? 'all'; + if (type !== 'all' && type !== '') { + where.type = type; + } + + const from = parseDate(params.dateFrom); + const to = parseDate(params.dateTo); + if (from || to) { + where.createdAt = { + ...(from ? { gte: from } : {}), + ...(to ? { lte: to } : {}), + }; + } + + const userId = trim(params.userId); + if (userId) { + where.request = { is: { userId } }; + } + + const audiobookQuery = trim(params.audiobookQuery); + if (audiobookQuery) { + where.request = { + is: { + ...(where.request?.is ?? {}), + audiobook: { + is: { + OR: [ + { title: { contains: audiobookQuery, mode: 'insensitive' } }, + { author: { contains: audiobookQuery, mode: 'insensitive' } }, + ], + }, + }, + }, + }; + } + + const errorsOnly = isTruthy(params.hasError); + const search = trim(params.search); + + const errorsOr = errorsOnly + ? [ + { status: { in: [...ERROR_STATUSES] } }, + { errorMessage: { not: null } }, + ] + : null; + + const searchOr = search + ? [ + { bullJobId: { startsWith: search } }, + { errorMessage: { contains: search, mode: 'insensitive' } }, + // TODO: revisit if slow — consider denormalized lastEventMessage on Job + { events: { some: { message: { contains: search, mode: 'insensitive' } } } }, + { request: { is: { audiobook: { is: { title: { contains: search, mode: 'insensitive' } } } } } }, + { request: { is: { audiobook: { is: { author: { contains: search, mode: 'insensitive' } } } } } }, + { request: { is: { user: { is: { plexUsername: { contains: search, mode: 'insensitive' } } } } } }, + ] + : null; + + if (errorsOr && searchOr) { + where.AND = [{ OR: errorsOr }, { OR: searchOr }]; + } else if (errorsOr) { + where.OR = errorsOr; + } else if (searchOr) { + where.OR = searchOr; + } + + return where; +} + export async function GET(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { const { searchParams } = new URL(request.url); - const page = parseInt(searchParams.get('page') || '1'); - const limit = parseInt(searchParams.get('limit') || '100'); - const status = searchParams.get('status') || 'all'; - const type = searchParams.get('type') || 'all'; + const page = parsePage(searchParams.get('page')); + const limit = parseLimit(searchParams.get('limit')); + + const where = buildLogsWhere({ + status: searchParams.get('status'), + type: searchParams.get('type'), + search: searchParams.get('search'), + dateFrom: searchParams.get('dateFrom'), + dateTo: searchParams.get('dateTo'), + hasError: searchParams.get('hasError'), + userId: searchParams.get('userId'), + audiobookQuery: searchParams.get('audiobookQuery'), + }); const skip = (page - 1) * limit; - // Build where clause - const where: any = {}; - if (status !== 'all') { - where.status = status; - } - if (type !== 'all') { - where.type = type; - } - const [logs, totalCount] = await Promise.all([ prisma.job.findMany({ where, diff --git a/src/lib/constants/job-labels.ts b/src/lib/constants/job-labels.ts new file mode 100644 index 0000000..7ac33b9 --- /dev/null +++ b/src/lib/constants/job-labels.ts @@ -0,0 +1,26 @@ +/** + * Component: Job Type Display Labels + * Documentation: documentation/backend/services/scheduler.md + */ + +// Short, human-readable labels for every job type that can appear in the +// admin Logs page or scheduled-jobs dropdown. Insertion order is the display +// order used by the Logs filter dropdown. +export const JOB_TYPE_LABELS: Record = { + search_indexers: 'Search Indexers', + download_torrent: 'Download Torrent', + monitor_download: 'Monitor Download', + organize_files: 'Organize Files', + scan_plex: 'Library Scan', + match_plex: 'Library Match', + plex_library_scan: 'Library Scan (Scheduled)', + plex_recently_added_check: 'Recently Added Check', + audible_refresh: 'Audible Refresh', + retry_missing_torrents: 'Retry Missing Torrents', + retry_failed_imports: 'Retry Failed Imports', + cleanup_seeded_torrents: 'Cleanup Seeded Torrents', + monitor_rss_feeds: 'Monitor RSS Feeds', + find_missing_ebooks: 'Find Missing Ebooks', + sync_reading_shelves: 'Sync Reading Shelves', + check_watched_lists: 'Check Watched Lists', +}; diff --git a/src/lib/constants/log-filters.ts b/src/lib/constants/log-filters.ts new file mode 100644 index 0000000..eae8029 --- /dev/null +++ b/src/lib/constants/log-filters.ts @@ -0,0 +1,130 @@ +/** + * Component: Admin Logs — Filter Constants & Helpers + * Documentation: documentation/admin-dashboard.md + * + * Owns: date-range preset definitions + helpers, status dropdown labels. + * Does NOT own: VALID_LIMITS, VALID_STATUSES, DEFAULT_LIMIT — those live in + * `src/app/admin/logs/types.ts` (the Stage-0 contract). This module imports + * `VALID_STATUSES` from there so status labels track the canonical value list. + */ + +import { VALID_STATUSES, type LogStatus } from '@/app/admin/logs/types'; + +// --------------------------------------------------------------------------- +// Date-range presets — preset id encodes the meaning, durationMs the window. +// `custom` and `all_time` carry null durationMs (sentinels handled by helpers). +// Insertion order is the display order in the picker. +// --------------------------------------------------------------------------- +export type DatePresetId = + | 'last_hour' + | 'last_24h' + | 'last_7d' + | 'last_30d' + | 'custom' + | 'all_time'; + +export interface DatePreset { + id: DatePresetId; + label: string; + durationMs: number | null; +} + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +export const DATE_PRESETS: readonly DatePreset[] = [ + { id: 'last_hour', label: 'Last hour', durationMs: HOUR_MS }, + { id: 'last_24h', label: 'Last 24h', durationMs: DAY_MS }, + { id: 'last_7d', label: 'Last 7 days', durationMs: 7 * DAY_MS }, + { id: 'last_30d', label: 'Last 30 days', durationMs: 30 * DAY_MS }, + { id: 'custom', label: 'Custom', durationMs: null }, + { id: 'all_time', label: 'All time', durationMs: null }, +]; + +/** Hydrate-time default per Zach Resolution #1. Used by useLogsUrlState only on first mount. */ +export const DEFAULT_DATE_PRESET_ID: DatePresetId = 'last_7d'; + +/** Tolerance for matching a stored `dateFrom` against a moving preset window. */ +const PRESET_MATCH_TOLERANCE_MS = 60 * 1000; + +/** + * Translate a preset id into a wire (dateFrom/dateTo) range. + * - For sliding-window presets, `to` stays null ("until now"). + * - For `custom`, returns the current values unchanged — callers should keep + * what the user typed rather than overwrite with nulls. + * - For `all_time`, both are null (no bound). + */ +export function presetToRange( + id: DatePresetId, + now: Date = new Date() +): { dateFrom: string | null; dateTo: string | null } { + if (id === 'all_time' || id === 'custom') { + return { dateFrom: null, dateTo: null }; + } + const preset = DATE_PRESETS.find((p) => p.id === id); + if (!preset || preset.durationMs == null) { + return { dateFrom: null, dateTo: null }; + } + return { + dateFrom: new Date(now.getTime() - preset.durationMs).toISOString(), + dateTo: null, + }; +} + +/** + * Identify which preset (if any) the current dateFrom/dateTo pair represents. + * - both null → 'all_time' + * - dateFrom within tolerance of `now - presetDuration`, no dateTo → that preset + * - anything else (e.g. dateTo set, or dateFrom outside tolerance) → 'custom' + */ +export function getActivePresetId( + dateFrom: string | null, + dateTo: string | null, + now: Date = new Date() +): DatePresetId { + if (dateFrom == null && dateTo == null) return 'all_time'; + if (dateTo != null) return 'custom'; + if (dateFrom == null) return 'custom'; + + const fromMs = new Date(dateFrom).getTime(); + if (!Number.isFinite(fromMs)) return 'custom'; + + const nowMs = now.getTime(); + for (const preset of DATE_PRESETS) { + if (preset.durationMs == null) continue; + const expected = nowMs - preset.durationMs; + if (Math.abs(fromMs - expected) <= PRESET_MATCH_TOLERANCE_MS) { + return preset.id; + } + } + return 'custom'; +} + +// --------------------------------------------------------------------------- +// Status dropdown — pair labels with the canonical VALID_STATUSES value list. +// Adding a status only requires editing types.ts; the label here can be tuned +// independently for display copy. +// --------------------------------------------------------------------------- +const STATUS_LABEL_OVERRIDES: Partial> = { + all: 'All Statuses', +}; + +function capitalize(s: string): string { + return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1); +} + +export interface StatusOption { + value: LogStatus; + label: string; +} + +export const STATUS_OPTIONS: readonly StatusOption[] = VALID_STATUSES.map((value) => ({ + value, + label: STATUS_LABEL_OVERRIDES[value] ?? capitalize(value), +})); + +/** Lookup a status's display label, falling back to capitalization. */ +export function getStatusLabel(value: string): string { + const match = STATUS_OPTIONS.find((opt) => opt.value === value); + return match?.label ?? capitalize(value); +} diff --git a/tests/api/admin-logs.routes.test.ts b/tests/api/admin-logs.routes.test.ts index 3455a01..79167c7 100644 --- a/tests/api/admin-logs.routes.test.ts +++ b/tests/api/admin-logs.routes.test.ts @@ -21,6 +21,18 @@ vi.mock('@/lib/middleware/auth', () => ({ requireAdmin: requireAdminMock, })); +async function callRoute(query: string = '') { + prismaMock.job.findMany.mockResolvedValueOnce([]); + prismaMock.job.count.mockResolvedValueOnce(0); + const { GET } = await import('@/app/api/admin/logs/route'); + const url = `http://localhost/api/admin/logs${query ? `?${query}` : ''}`; + const response = await GET({ url } as any); + const payload = await response.json(); + const findManyArgs = prismaMock.job.findMany.mock.calls[0][0]; + const countArgs = prismaMock.job.count.mock.calls[0][0]; + return { response, payload, findManyArgs, countArgs }; +} + describe('Admin logs route', () => { beforeEach(() => { vi.clearAllMocks(); @@ -34,12 +46,209 @@ describe('Admin logs route', () => { prismaMock.job.count.mockResolvedValueOnce(1); const { GET } = await import('@/app/api/admin/logs/route'); - const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=10' } as any); + const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=25' } as any); const payload = await response.json(); expect(payload.logs).toHaveLength(1); expect(payload.pagination.total).toBe(1); }); + + describe('where composition', () => { + it('builds empty where when no filters provided', async () => { + const { findManyArgs } = await callRoute(); + expect(findManyArgs.where).toEqual({}); + }); + + it('applies status filter only when not "all"', async () => { + const { findManyArgs } = await callRoute('status=failed'); + expect(findManyArgs.where).toEqual({ status: 'failed' }); + }); + + it('skips status when value is "all"', async () => { + const { findManyArgs } = await callRoute('status=all'); + expect(findManyArgs.where).toEqual({}); + }); + + it('applies type filter only when not "all"', async () => { + const { findManyArgs } = await callRoute('type=scan_plex'); + expect(findManyArgs.where).toEqual({ type: 'scan_plex' }); + }); + + it('applies dateFrom and dateTo as createdAt range', async () => { + const { findManyArgs } = await callRoute( + 'dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z' + ); + expect(findManyArgs.where.createdAt).toEqual({ + gte: new Date('2026-01-01T00:00:00.000Z'), + lte: new Date('2026-02-01T00:00:00.000Z'), + }); + }); + + it('silently drops invalid date strings', async () => { + const { findManyArgs } = await callRoute('dateFrom=not-a-date&dateTo=also-not-a-date'); + expect(findManyArgs.where.createdAt).toBeUndefined(); + }); + + it('applies hasError=true as OR of failed/stuck status or non-null errorMessage', async () => { + const { findManyArgs } = await callRoute('hasError=true'); + expect(findManyArgs.where.OR).toEqual([ + { status: { in: ['failed', 'stuck'] } }, + { errorMessage: { not: null } }, + ]); + }); + + it('also accepts hasError=1 as truthy', async () => { + const { findManyArgs } = await callRoute('hasError=1'); + expect(findManyArgs.where.OR).toEqual([ + { status: { in: ['failed', 'stuck'] } }, + { errorMessage: { not: null } }, + ]); + }); + + it('treats hasError=false as no errors filter', async () => { + const { findManyArgs } = await callRoute('hasError=false'); + expect(findManyArgs.where.OR).toBeUndefined(); + }); + + it('applies userId filter via request.is.userId', async () => { + const { findManyArgs } = await callRoute('userId=user-abc-123'); + expect(findManyArgs.where.request).toEqual({ is: { userId: 'user-abc-123' } }); + }); + + it('applies audiobookQuery as OR-contains on audiobook title/author', async () => { + const { findManyArgs } = await callRoute('audiobookQuery=Sanderson'); + expect(findManyArgs.where.request).toEqual({ + is: { + audiobook: { + is: { + OR: [ + { title: { contains: 'Sanderson', mode: 'insensitive' } }, + { author: { contains: 'Sanderson', mode: 'insensitive' } }, + ], + }, + }, + }, + }); + }); + + it('search applies six-column OR with bullJobId startsWith (case-sensitive)', async () => { + const { findManyArgs } = await callRoute('search=abc123'); + const or = findManyArgs.where.OR; + expect(Array.isArray(or)).toBe(true); + expect(or).toHaveLength(6); + expect(or[0]).toEqual({ bullJobId: { startsWith: 'abc123' } }); + expect(or[1]).toEqual({ errorMessage: { contains: 'abc123', mode: 'insensitive' } }); + }); + + it('search includes events.some.message clause for event-text search', async () => { + const { findManyArgs } = await callRoute('search=timeout'); + const hasEventClause = findManyArgs.where.OR.some( + (clause: any) => + clause.events?.some?.message?.contains === 'timeout' && + clause.events?.some?.message?.mode === 'insensitive' + ); + expect(hasEventClause).toBe(true); + }); + + it('search includes audiobook title/author and plexUsername clauses', async () => { + const { findManyArgs } = await callRoute('search=foo'); + const or = findManyArgs.where.OR; + const findRequestClause = (path: (clause: any) => any) => + or.find((clause: any) => path(clause) === 'foo'); + expect(findRequestClause((c: any) => c.request?.is?.audiobook?.is?.title?.contains)).toBeTruthy(); + expect(findRequestClause((c: any) => c.request?.is?.audiobook?.is?.author?.contains)).toBeTruthy(); + expect(findRequestClause((c: any) => c.request?.is?.user?.is?.plexUsername?.contains)).toBeTruthy(); + }); + + it('treats whitespace-only search as no search', async () => { + const { findManyArgs } = await callRoute('search=%20%20%20'); + expect(findManyArgs.where.OR).toBeUndefined(); + }); + + it('treats whitespace-only audiobookQuery as no filter', async () => { + const { findManyArgs } = await callRoute('audiobookQuery=%20'); + expect(findManyArgs.where.request).toBeUndefined(); + }); + + it('combines hasError and search under top-level AND wrapper', async () => { + const { findManyArgs } = await callRoute('hasError=true&search=oom'); + expect(findManyArgs.where.AND).toBeDefined(); + expect(findManyArgs.where.AND).toHaveLength(2); + expect(findManyArgs.where.OR).toBeUndefined(); + const orClauses = findManyArgs.where.AND.map((c: any) => c.OR); + expect(orClauses[0]).toEqual([ + { status: { in: ['failed', 'stuck'] } }, + { errorMessage: { not: null } }, + ]); + expect(Array.isArray(orClauses[1])).toBe(true); + expect(orClauses[1]).toHaveLength(6); + }); + + it('combines all filters together', async () => { + const { findManyArgs } = await callRoute( + 'status=failed&type=scan_plex&dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z&userId=user-1&audiobookQuery=Way%20of%20Kings&hasError=true&search=disk' + ); + const where = findManyArgs.where; + expect(where.status).toBe('failed'); + expect(where.type).toBe('scan_plex'); + expect(where.createdAt.gte).toEqual(new Date('2026-01-01T00:00:00.000Z')); + expect(where.createdAt.lte).toEqual(new Date('2026-02-01T00:00:00.000Z')); + expect(where.request.is.userId).toBe('user-1'); + expect(where.request.is.audiobook.is.OR).toHaveLength(2); + expect(where.AND).toHaveLength(2); + }); + + it('uses identical where for findMany and count', async () => { + const { findManyArgs, countArgs } = await callRoute('status=failed&hasError=true'); + expect(countArgs.where).toEqual(findManyArgs.where); + }); + }); + + describe('limit clamp', () => { + const cases: Array<[string | null, number]> = [ + ['25', 25], + ['50', 50], + ['100', 100], + ['24', 50], + ['75', 50], + ['101', 50], + ['abc', 50], + [null, 50], + ]; + + for (const [raw, expected] of cases) { + it(`limit=${raw} → take ${expected}`, async () => { + const query = raw === null ? '' : `limit=${raw}`; + const { findManyArgs, payload } = await callRoute(query); + expect(findManyArgs.take).toBe(expected); + expect(payload.pagination.limit).toBe(expected); + }); + } + }); + + describe('pagination math', () => { + it('page=2 with limit=50 and total=75 returns totalPages=2 and skip=50', async () => { + prismaMock.job.findMany.mockResolvedValueOnce([]); + prismaMock.job.count.mockResolvedValueOnce(75); + const { GET } = await import('@/app/api/admin/logs/route'); + const response = await GET({ + url: 'http://localhost/api/admin/logs?page=2&limit=50', + } as any); + const payload = await response.json(); + const findManyArgs = prismaMock.job.findMany.mock.calls[0][0]; + + expect(findManyArgs.skip).toBe(50); + expect(findManyArgs.take).toBe(50); + expect(payload.pagination.page).toBe(2); + expect(payload.pagination.limit).toBe(50); + expect(payload.pagination.total).toBe(75); + expect(payload.pagination.totalPages).toBe(2); + }); + + it('coerces invalid page to 1', async () => { + const { findManyArgs, payload } = await callRoute('page=-3'); + expect(findManyArgs.skip).toBe(0); + expect(payload.pagination.page).toBe(1); + }); + }); }); - - diff --git a/tests/app/admin-logs-chips.test.tsx b/tests/app/admin-logs-chips.test.tsx new file mode 100644 index 0000000..1187a3e --- /dev/null +++ b/tests/app/admin-logs-chips.test.tsx @@ -0,0 +1,140 @@ +/** + * Component: Admin Logs — ActiveFilterChips Tests + * Documentation: documentation/admin-dashboard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ActiveFilterChips from '@/app/admin/logs/components/ActiveFilterChips'; +import { DEFAULT_FILTER_STATE, type LogsFilterState } from '@/app/admin/logs/types'; + +const setFiltersMock = vi.fn(); +const removeFilterMock = vi.fn(); +const clearAllMock = vi.fn(); +let mockFilters: LogsFilterState = { ...DEFAULT_FILTER_STATE }; + +vi.mock('@/app/admin/logs/hooks/useLogsUrlState', () => ({ + useLogsUrlState: () => ({ + filters: mockFilters, + setFilters: setFiltersMock, + setSearchInput: vi.fn(), + searchInput: mockFilters.search, + clearAll: clearAllMock, + removeFilter: removeFilterMock, + }), +})); + +const findUserByIdMock = vi.fn(); +vi.mock('@/app/admin/logs/hooks/useUserSearch', () => ({ + useUserSearch: () => ({ + users: [], + filterByQuery: vi.fn(), + findUserById: findUserByIdMock, + isLoading: false, + error: null, + }), +})); + +describe('ActiveFilterChips', () => { + beforeEach(() => { + setFiltersMock.mockReset(); + removeFilterMock.mockReset(); + findUserByIdMock.mockReset(); + mockFilters = { ...DEFAULT_FILTER_STATE }; + }); + + it('renders nothing when all filters are at default and no search', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders a status chip with the correct aria-label and label', () => { + mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' }; + render(); + const chip = screen.getByRole('button', { name: 'Remove filter: status' }); + expect(chip).toHaveTextContent('Status: Failed'); + }); + + it('renders a job-type chip using JOB_TYPE_LABELS for the display label', () => { + mockFilters = { ...DEFAULT_FILTER_STATE, type: 'search_indexers' }; + render(); + const chip = screen.getByRole('button', { name: 'Remove filter: job type' }); + expect(chip).toHaveTextContent('Type: Search Indexers'); + }); + + it('renders an Errors only chip when hasError is true', () => { + mockFilters = { ...DEFAULT_FILTER_STATE, hasError: true }; + render(); + const chip = screen.getByRole('button', { name: 'Remove filter: errors only' }); + expect(chip).toHaveTextContent('Errors only'); + }); + + it('clicking a chip calls removeFilter with the correct key', () => { + mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' }; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Remove filter: status' })); + expect(removeFilterMock).toHaveBeenCalledWith('status'); + }); + + it('clicking the date chip clears both dateFrom and dateTo via setFilters', () => { + mockFilters = { + ...DEFAULT_FILTER_STATE, + dateFrom: '2026-05-10T00:00:00.000Z', + dateTo: '2026-05-12T00:00:00.000Z', + }; + render(); + const chip = screen.getByRole('button', { name: 'Remove filter: date range' }); + fireEvent.click(chip); + expect(setFiltersMock).toHaveBeenCalledWith({ dateFrom: null, dateTo: null }); + }); + + it('renders a search chip when search is non-empty', () => { + mockFilters = { ...DEFAULT_FILTER_STATE, search: 'timeout' }; + render(); + const chip = screen.getByRole('button', { name: 'Remove filter: search' }); + expect(chip).toHaveTextContent('Search: "timeout"'); + fireEvent.click(chip); + expect(removeFilterMock).toHaveBeenCalledWith('search'); + }); + + it('user chip uses resolved plexUsername when available, falls back to id', () => { + findUserByIdMock.mockReturnValue({ id: 'user-1', plexUsername: 'alice', role: 'user' }); + mockFilters = { ...DEFAULT_FILTER_STATE, userId: 'user-1' }; + const { unmount } = render(); + expect( + screen.getByRole('button', { name: 'Remove filter: user' }) + ).toHaveTextContent('User: alice'); + unmount(); + + findUserByIdMock.mockReturnValue(undefined); + render(); + expect( + screen.getByRole('button', { name: 'Remove filter: user' }) + ).toHaveTextContent('User: user-1'); + }); + + it('audiobook chip shows the query string', () => { + mockFilters = { ...DEFAULT_FILTER_STATE, audiobookQuery: 'Dune' }; + render(); + const chip = screen.getByRole('button', { name: 'Remove filter: audiobook' }); + expect(chip).toHaveTextContent('Book: "Dune"'); + }); + + it('renders all chips together when multiple filters are active', () => { + mockFilters = { + ...DEFAULT_FILTER_STATE, + status: 'failed', + type: 'search_indexers', + hasError: true, + search: 'oops', + audiobookQuery: 'Dune', + }; + render(); + const group = screen.getByRole('group', { name: 'Active filters' }); + // Five chips for five active values. + expect(group.querySelectorAll('button')).toHaveLength(5); + }); +}); diff --git a/tests/app/admin-logs-filters.test.tsx b/tests/app/admin-logs-filters.test.tsx new file mode 100644 index 0000000..84da79b --- /dev/null +++ b/tests/app/admin-logs-filters.test.tsx @@ -0,0 +1,214 @@ +/** + * Component: Admin Logs — LogsFilters Tests + * Documentation: documentation/admin-dashboard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LogsFilters from '@/app/admin/logs/components/LogsFilters'; +import { DEFAULT_FILTER_STATE, type LogsFilterState } from '@/app/admin/logs/types'; + +// ---- Mock hooks (the seam between components and page-level state) ------- + +const setFiltersMock = vi.fn(); +const clearAllMock = vi.fn(); +const removeFilterMock = vi.fn(); +const setSearchInputMock = vi.fn(); +let mockFilters: LogsFilterState = { ...DEFAULT_FILTER_STATE }; + +vi.mock('@/app/admin/logs/hooks/useLogsUrlState', () => ({ + useLogsUrlState: () => ({ + filters: mockFilters, + setFilters: setFiltersMock, + setSearchInput: setSearchInputMock, + searchInput: mockFilters.search, + clearAll: clearAllMock, + removeFilter: removeFilterMock, + }), +})); + +const registerMock = vi.fn(); +const unregisterMock = vi.fn(); +const useRegisterPauseReasonMock = vi.fn(); + +vi.mock('@/app/admin/logs/hooks/useAutoRefreshControl', () => ({ + useAutoRefreshControl: () => ({ + register: registerMock, + unregister: unregisterMock, + isPaused: false, + isRunning: true, + pauseReasons: [], + enabled: true, + setEnabled: vi.fn(), + effectiveInterval: 10000, + manualRefresh: vi.fn(), + setMutate: vi.fn(), + setLastUpdatedAt: vi.fn(), + lastUpdatedAt: 0, + }), + useRegisterPauseReason: (reason: string, active: boolean) => { + useRegisterPauseReasonMock(reason, active); + React.useEffect(() => { + if (active) registerMock(reason); + else unregisterMock(reason); + return () => unregisterMock(reason); + }, [reason, active]); + }, +})); + +const filterByQueryMock = vi.fn(); +const findUserByIdMock = vi.fn(); + +vi.mock('@/app/admin/logs/hooks/useUserSearch', () => ({ + useUserSearch: () => ({ + users: [ + { id: 'user-1', plexUsername: 'alice', role: 'user' }, + { id: 'user-2', plexUsername: 'bob', role: 'admin' }, + ], + filterByQuery: filterByQueryMock, + findUserById: findUserByIdMock, + isLoading: false, + error: null, + }), +})); + +// ---- Tests --------------------------------------------------------------- + +describe('LogsFilters', () => { + beforeEach(() => { + setFiltersMock.mockReset(); + clearAllMock.mockReset(); + removeFilterMock.mockReset(); + registerMock.mockReset(); + unregisterMock.mockReset(); + useRegisterPauseReasonMock.mockReset(); + filterByQueryMock.mockReset(); + findUserByIdMock.mockReset(); + mockFilters = { ...DEFAULT_FILTER_STATE }; + filterByQueryMock.mockReturnValue([ + { id: 'user-1', plexUsername: 'alice', role: 'user' }, + { id: 'user-2', plexUsername: 'bob', role: 'admin' }, + ]); + findUserByIdMock.mockReturnValue(undefined); + }); + + it('renders the Status dropdown with all canonical options', () => { + render(); + const select = screen.getByLabelText('Status') as HTMLSelectElement; + const values = Array.from(select.options).map((o) => o.value); + expect(values).toEqual(['all', 'pending', 'active', 'completed', 'failed', 'delayed', 'stuck']); + }); + + it('renders the Job Type dropdown with All Types + JOB_TYPE_LABELS in insertion order', () => { + render(); + const select = screen.getByLabelText('Job Type') as HTMLSelectElement; + const values = Array.from(select.options).map((o) => o.value); + // First option is 'all', followed by every JOB_TYPE_LABELS key. + expect(values[0]).toBe('all'); + expect(values.slice(1, 5)).toEqual([ + 'search_indexers', + 'download_torrent', + 'monitor_download', + 'organize_files', + ]); + }); + + it('calls setFilters({ status }) when the Status dropdown changes', () => { + render(); + const select = screen.getByLabelText('Status') as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'failed' } }); + expect(setFiltersMock).toHaveBeenCalledWith({ status: 'failed' }); + }); + + it('clicking a preset date option calls setFilters with computed dateFrom and dateTo null', () => { + render(); + const dateSelect = screen.getByLabelText('Date Range') as HTMLSelectElement; + fireEvent.change(dateSelect, { target: { value: 'last_7d' } }); + expect(setFiltersMock).toHaveBeenCalledTimes(1); + const [call] = setFiltersMock.mock.calls; + const payload = call[0] as Partial; + expect(payload.dateTo).toBeNull(); + expect(payload.dateFrom).toMatch(/^\d{4}-\d{2}-\d{2}T/); + const fromMs = new Date(payload.dateFrom as string).getTime(); + // 7 days ago, ±60s tolerance (test execution time). + const expected = Date.now() - 7 * 24 * 60 * 60 * 1000; + expect(Math.abs(fromMs - expected)).toBeLessThan(60_000); + }); + + it('selecting Custom reveals datetime-local inputs', () => { + render(); + const dateSelect = screen.getByLabelText('Date Range') as HTMLSelectElement; + fireEvent.change(dateSelect, { target: { value: 'custom' } }); + expect(screen.getByLabelText('Date from')).toBeInTheDocument(); + expect(screen.getByLabelText('Date to')).toBeInTheDocument(); + }); + + it('typing in the Audiobook input calls setFilters with audiobookQuery', () => { + render(); + const input = screen.getByLabelText('Audiobook') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'Dune' } }); + expect(setFiltersMock).toHaveBeenCalledWith({ audiobookQuery: 'Dune' }); + }); + + it('user typeahead selection calls setFilters with the user id', () => { + render(); + const input = screen.getByLabelText('User') as HTMLInputElement; + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'al' } }); + // The popover renders the filtered options; click "alice" via mouseDown + // (the component uses onMouseDown to avoid the blur race). + const option = screen.getByRole('option', { name: /alice/ }); + fireEvent.mouseDown(option); + expect(setFiltersMock).toHaveBeenCalledWith({ userId: 'user-1' }); + }); + + it('user typeahead clear button calls setFilters with userId null', () => { + findUserByIdMock.mockReturnValue({ id: 'user-1', plexUsername: 'alice', role: 'user' }); + mockFilters = { ...DEFAULT_FILTER_STATE, userId: 'user-1' }; + render(); + const clear = screen.getByRole('button', { name: 'Clear user filter' }); + fireEvent.click(clear); + expect(setFiltersMock).toHaveBeenCalledWith({ userId: null }); + }); + + it('hides "Clear all filters" when no filters or search are active', () => { + render(); + expect(screen.queryByText('Clear all filters')).not.toBeInTheDocument(); + }); + + it('shows "Clear all filters" when at least one filter is active and clears on click', () => { + mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' }; + render(); + const button = screen.getByText('Clear all filters'); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(clearAllMock).toHaveBeenCalledTimes(1); + }); + + it('registers a pause reason when the Status select is focused and unregisters on blur', () => { + render(); + const select = screen.getByLabelText('Status') as HTMLSelectElement; + fireEvent.focus(select); + expect(registerMock).toHaveBeenCalledWith('logs-status-dropdown'); + fireEvent.blur(select); + expect(unregisterMock).toHaveBeenCalledWith('logs-status-dropdown'); + }); + + it('custom datetime-local input emits UTC ISO via setFilters', () => { + render(); + fireEvent.change(screen.getByLabelText('Date Range'), { target: { value: 'custom' } }); + const fromInput = screen.getByLabelText('Date from') as HTMLInputElement; + fireEvent.change(fromInput, { target: { value: '2026-01-15T10:30' } }); + expect(setFiltersMock).toHaveBeenCalled(); + const lastCall = setFiltersMock.mock.calls.at(-1)?.[0] as Partial; + expect(lastCall.dateFrom).toMatch(/Z$/); + // The submitted ISO must parse to the same wall-clock time the user typed, + // interpreted as local. Round-trip check: + const parsed = new Date(lastCall.dateFrom as string); + const localRoundTrip = new Date(2026, 0, 15, 10, 30); + expect(parsed.getTime()).toBe(localRoundTrip.getTime()); + }); +}); diff --git a/tests/app/admin-logs.page.test.tsx b/tests/app/admin-logs.page.test.tsx index dd4b38f..7b26cd5 100644 --- a/tests/app/admin-logs.page.test.tsx +++ b/tests/app/admin-logs.page.test.tsx @@ -6,11 +6,23 @@ // @vitest-environment jsdom import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import AdminLogsPage from '@/app/admin/logs/page'; +import { + buildLogsApiKey, + DEFAULT_FILTER_STATE, + LogsData, + LogsFilterState, +} from '@/app/admin/logs/types'; + +// =========================================================================== +// Mocks +// =========================================================================== const useSWRMock = vi.hoisted(() => vi.fn()); +const routerReplaceMock = vi.hoisted(() => vi.fn()); +const searchParamsState = vi.hoisted(() => ({ value: new URLSearchParams() })); vi.mock('swr', () => ({ default: (...args: any[]) => useSWRMock(...args), @@ -20,108 +32,562 @@ vi.mock('@/lib/utils/api', () => ({ authenticatedFetcher: vi.fn(), })); -describe('AdminLogsPage', () => { - beforeEach(() => { - useSWRMock.mockReset(); - }); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: routerReplaceMock, push: vi.fn() }), + usePathname: () => '/admin/logs', + useSearchParams: () => searchParamsState.value, +})); - it('renders logs and toggles detail rows', async () => { - useSWRMock.mockImplementation(() => ({ - data: { - logs: [ - { - id: 'log-1', - bullJobId: 'bull-1', - type: 'search_indexers', - status: 'failed', - priority: 1, - attempts: 2, - maxAttempts: 3, - errorMessage: 'Search failed', - startedAt: '2024-01-01T00:00:00Z', - completedAt: '2024-01-01T00:02:00Z', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:02:00Z', - result: { retries: 2 }, - events: [ - { - id: 'event-1', - level: 'error', - context: 'SearchJob', - message: 'Indexer timeout', - metadata: { indexer: 'Example' }, - createdAt: '2024-01-01T00:01:00Z', - }, - ], - request: { - id: 'req-1', - audiobook: { title: 'Search Book', author: 'Author' }, - user: { plexUsername: 'User' }, - }, - }, - ], - pagination: { page: 1, limit: 50, total: 1, totalPages: 1 }, +// useUserSearch fires its own SWR call for /api/admin/users; we branch the +// SWR mock by URL so both the logs key and the users key get sensible defaults. +const mockMutate = vi.fn(); +function defaultSwrImpl(logsResponse: { data?: LogsData; error?: Error } = {}) { + return (key: string) => { + if (typeof key === 'string' && key.startsWith('/api/admin/users')) { + return { data: { users: [] }, error: undefined, mutate: vi.fn(), isLoading: false }; + } + return { + data: logsResponse.data, + error: logsResponse.error, + mutate: mockMutate, + isLoading: false, + }; + }; +} + +// =========================================================================== +// Fixtures +// =========================================================================== + +function makeLog(overrides: Partial = {}): any { + return { + id: 'log-1', + bullJobId: 'bull-1', + type: 'search_indexers', + status: 'failed', + priority: 1, + attempts: 2, + maxAttempts: 3, + errorMessage: 'Search failed', + startedAt: '2024-01-01T00:00:00Z', + completedAt: '2024-01-01T00:02:00Z', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:02:00Z', + result: { retries: 2 }, + events: [ + { + id: 'event-1', + level: 'error', + context: 'SearchJob', + message: 'Indexer timeout', + metadata: { indexer: 'Example' }, + createdAt: '2024-01-01T00:01:00Z', }, - error: undefined, - })); + ], + request: { + id: 'req-1', + audiobook: { title: 'Search Book', author: 'Author' }, + user: { plexUsername: 'User' }, + }, + ...overrides, + }; +} +function makeData(logs: any[] = [makeLog()], pagination: Partial = {}): LogsData { + return { + logs, + pagination: { + page: 1, + limit: 50, + total: logs.length, + totalPages: Math.max(1, Math.ceil(logs.length / 50)), + ...pagination, + }, + }; +} + +// =========================================================================== +// Setup / teardown +// =========================================================================== + +beforeEach(() => { + vi.useRealTimers(); + useSWRMock.mockReset(); + routerReplaceMock.mockReset(); + mockMutate.mockReset(); + searchParamsState.value = new URLSearchParams(); +}); + +afterEach(() => { + vi.useRealTimers(); + try { + window.sessionStorage.clear(); + } catch { + // ignore + } +}); + +// =========================================================================== +// Page-level tests +// =========================================================================== + +describe('AdminLogsPage', () => { + it('renders the page header and a desktop row from data', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); render(); expect(await screen.findByText('System Logs')).toBeInTheDocument(); expect(screen.getAllByText('Search Book')[0]).toBeInTheDocument(); - - fireEvent.click(screen.getAllByRole('button', { name: 'Show Details' })[0]); - expect(screen.getAllByText('Event Log')[0]).toBeInTheDocument(); - expect(screen.getAllByText('Job Result')[0]).toBeInTheDocument(); - expect(screen.getAllByText('Error')[0]).toBeInTheDocument(); - - fireEvent.click(screen.getAllByRole('button', { name: 'Hide Details' })[0]); - expect(screen.queryByText('Event Log')).not.toBeInTheDocument(); }); - it('updates the swr key when filters change', async () => { - useSWRMock.mockImplementation(() => ({ - data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } }, - error: undefined, - })); - + it('shows skeleton on initial load (no data, no error)', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: undefined, error: undefined })); render(); - - const statusSelect = screen - .getByText('Status', { selector: 'label' }) - .parentElement?.querySelector('select'); - expect(statusSelect).not.toBeNull(); - fireEvent.change(statusSelect as HTMLSelectElement, { target: { value: 'completed' } }); - - await waitFor(() => { - expect(useSWRMock).toHaveBeenCalledWith( - '/api/admin/logs?page=1&limit=50&status=completed&type=all', - expect.any(Function), - expect.any(Object) - ); - }); + expect(await screen.findByTestId('log-skeleton-mobile')).toBeInTheDocument(); + expect(screen.getByTestId('log-skeleton-desktop')).toBeInTheDocument(); }); it('renders error state when logs fail to load', async () => { - useSWRMock.mockImplementation(() => ({ - data: undefined, - error: new Error('Log failure'), - })); - + useSWRMock.mockImplementation( + defaultSwrImpl({ data: undefined, error: new Error('Log failure') }) + ); render(); expect(await screen.findByText('Error Loading Logs')).toBeInTheDocument(); expect(screen.getByText('Log failure')).toBeInTheDocument(); }); - it('renders empty state when no logs are returned', async () => { - useSWRMock.mockImplementation(() => ({ - data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } }, - error: undefined, - })); - + it('uses buildLogsApiKey for the SWR key (with hydrate-time 7d default applied)', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); render(); - expect((await screen.findAllByText('No logs found'))[0]).toBeInTheDocument(); + await waitFor(() => { + const calls = useSWRMock.mock.calls.filter( + (c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs') + ); + expect(calls.length).toBeGreaterThan(0); + const key = calls[0][0]; + const params = new URLSearchParams(key.split('?')[1] ?? ''); + // Defaults: page=1, limit=50 always present. + expect(params.get('page')).toBe('1'); + expect(params.get('limit')).toBe('50'); + // Zach Resolution #1: hydrate-time Last-7-days default → dateFrom set, + // dateTo unset (sliding window to "now"). + const dateFrom = params.get('dateFrom'); + expect(dateFrom).not.toBeNull(); + expect(params.get('dateTo')).toBeNull(); + // Confirm the dateFrom is roughly 7 days ago (allow generous tolerance). + const fromMs = new Date(dateFrom as string).getTime(); + const expected = Date.now() - 7 * 24 * 60 * 60 * 1000; + expect(Math.abs(fromMs - expected)).toBeLessThan(60_000); + }); + }); + + it('updates the SWR key when Errors-only pill is activated', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); + render(); + + const pill = await screen.findByRole('button', { name: /errors only/i }); + fireEvent.click(pill); + + await waitFor(() => { + expect(routerReplaceMock).toHaveBeenCalled(); + const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1]; + expect(lastCall[0]).toContain('hasError=1'); + }); + }); + + it('renders fresh empty state when no rows, no filters, no search', async () => { + // Note: hydrate-time 7d default IS applied here, but the page's + // empty-state branch treats the implicit default as "not user-applied", + // so the "fresh" copy still wins. + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); + render(); + + expect( + await screen.findByText(/No background jobs have run yet/i) + ).toBeInTheDocument(); + }); + + it('skips hydrate-time 7d default when URL already has dateFrom', async () => { + searchParamsState.value = new URLSearchParams('dateFrom=2024-01-01T00:00:00.000Z'); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); + render(); + + await waitFor(() => { + const calls = useSWRMock.mock.calls.filter( + (c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs') + ); + const key = calls[calls.length - 1][0]; + const params = new URLSearchParams(key.split('?')[1] ?? ''); + // URL-provided dateFrom wins; hydrate default does NOT replace it. + expect(params.get('dateFrom')).toBe('2024-01-01T00:00:00.000Z'); + }); + }); + + it('retires hydrate default after user retires dates via setFilters', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + // First confirm hydrate is active. + await waitFor(() => { + const calls = useSWRMock.mock.calls.filter( + (c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs') + ); + const key = calls[calls.length - 1][0]; + expect(new URLSearchParams(key.split('?')[1]).get('dateFrom')).not.toBeNull(); + }); + + // Click Errors-only — this writes URL. The hydrate dates ride along in + // the merge, so URL now carries an explicit dateFrom. + const pill = await screen.findByRole('button', { name: /errors only/i }); + fireEvent.click(pill); + + await waitFor(() => { + expect(routerReplaceMock).toHaveBeenCalled(); + const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1]; + const params = new URLSearchParams((lastCall[0] as string).split('?')[1] ?? ''); + expect(params.get('hasError')).toBe('1'); + expect(params.get('dateFrom')).not.toBeNull(); + }); + }); + + it('renders search-no-match empty state with Clear search button', async () => { + searchParamsState.value = new URLSearchParams('search=foo'); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); + render(); + + expect(await screen.findByText(/No matches for/i)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /clear search and show all logs/i }) + ).toBeInTheDocument(); + }); + + it('renders filters-too-tight empty state with Clear filters button', async () => { + searchParamsState.value = new URLSearchParams('status=failed'); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); + render(); + + expect( + await screen.findByText(/No logs match your current filters/i) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(); + }); + + it('hydrates filter state from URL on mount', async () => { + searchParamsState.value = new URLSearchParams('status=failed&hasError=1&page=2'); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); + render(); + + await waitFor(() => { + const calls = useSWRMock.mock.calls.filter( + (c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs') + ); + expect(calls.length).toBeGreaterThan(0); + const key = calls[calls.length - 1][0]; + const params = new URLSearchParams(key.split('?')[1] ?? ''); + expect(params.get('status')).toBe('failed'); + expect(params.get('hasError')).toBe('1'); + expect(params.get('page')).toBe('2'); + }); + }); + + it('silently drops invalid URL params', async () => { + searchParamsState.value = new URLSearchParams( + 'status=garbage&type=not_a_type&limit=37&page=abc' + ); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); + render(); + + await waitFor(() => { + const calls = useSWRMock.mock.calls.filter( + (c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs') + ); + expect(calls.length).toBeGreaterThan(0); + const key = calls[calls.length - 1][0]; + const params = new URLSearchParams(key.split('?')[1] ?? ''); + // Invalid values silently dropped → defaults applied + expect(params.get('status')).toBeNull(); + expect(params.get('type')).toBeNull(); + // page + limit fall back to defaults (1 / 50) which the SWR key always sets + expect(params.get('page')).toBe('1'); + expect(params.get('limit')).toBe('50'); + }); + }); + + it('debounces search input — fast keystrokes produce ONE URL write', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + const search = await screen.findByLabelText(/search logs/i); + fireEvent.change(search, { target: { value: 'a' } }); + fireEvent.change(search, { target: { value: 'ab' } }); + fireEvent.change(search, { target: { value: 'abc' } }); + + // Wait past the 300ms debounce window — only ONE URL write should land, + // with the final value. + await waitFor( + () => { + const searchCalls = routerReplaceMock.mock.calls.filter((c: any[]) => + (c[0] as string).includes('search=') + ); + expect(searchCalls.length).toBe(1); + expect(searchCalls[0][0]).toContain('search=abc'); + }, + { timeout: 1500 } + ); + }); + + it('shows search clear (×) when populated and clears search on click', async () => { + searchParamsState.value = new URLSearchParams('search=foo'); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) })); + render(); + + // The toolbar's × button has aria-label="Clear search" (exact match). + const clearBtn = await screen.findByRole('button', { name: 'Clear search' }); + fireEvent.click(clearBtn); + + await waitFor(() => { + expect(routerReplaceMock).toHaveBeenCalled(); + const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1]; + expect(lastCall[0]).not.toContain('search='); + }); + }); + + it('Refresh-now button triggers SWR mutate', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + const refresh = await screen.findByRole('button', { name: /refresh now/i }); + fireEvent.click(refresh); + + expect(mockMutate).toHaveBeenCalled(); + }); + + it('Auto-refresh toggle persists state to sessionStorage', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + const toggle = await screen.findByRole('switch', { name: /auto-refresh/i }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(window.sessionStorage.getItem('admin-logs:auto-refresh-enabled')).toBe('0'); + }); + + it('Auto-refresh OFF makes effectiveInterval=0 in the SWR call', async () => { + window.sessionStorage.setItem('admin-logs:auto-refresh-enabled', '0'); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + await waitFor(() => { + const logsCalls = useSWRMock.mock.calls.filter( + (c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs') + ); + const lastCfg = logsCalls[logsCalls.length - 1][2]; + expect(lastCfg.refreshInterval).toBe(0); + }); + }); + + it('expanding a row pauses auto-refresh (refreshInterval=0)', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + const discloseButtons = await screen.findAllByRole('button', { name: /show details/i }); + fireEvent.click(discloseButtons[0]); + + await waitFor(() => { + const logsCalls = useSWRMock.mock.calls.filter( + (c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs') + ); + const lastCfg = logsCalls[logsCalls.length - 1][2]; + expect(lastCfg.refreshInterval).toBe(0); + }); + }); + + it('Live indicator shows Paused when auto-refresh disabled', async () => { + window.sessionStorage.setItem('admin-logs:auto-refresh-enabled', '0'); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + await waitFor(() => { + const indicator = screen.getByTestId('logs-live-indicator'); + expect(indicator.getAttribute('data-state')).toBe('paused'); + }); + }); + + it('pagination shows total result count and Page X of Y', async () => { + useSWRMock.mockImplementation( + defaultSwrImpl({ data: makeData([makeLog()], { total: 247, totalPages: 5 }) }) + ); + render(); + + const summary = await screen.findByTestId('logs-pagination-summary'); + expect(summary.textContent).toContain('247'); + expect(summary.textContent).toMatch(/Page\s*1\s*of\s*5/); + }); + + it('changing page-size triggers URL update with new limit and resets to page 1', async () => { + searchParamsState.value = new URLSearchParams('page=3'); + useSWRMock.mockImplementation( + defaultSwrImpl({ data: makeData([makeLog()], { page: 3, total: 200, totalPages: 4 }) }) + ); + render(); + + const sizeSelect = await screen.findByLabelText(/page size/i); + fireEvent.change(sizeSelect, { target: { value: '100' } }); + + await waitFor(() => { + const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1]; + expect(lastCall[0]).toContain('limit=100'); + expect(lastCall[0]).not.toContain('page='); + }); + }); + + it('changing a filter resets pagination to page 1', async () => { + searchParamsState.value = new URLSearchParams('page=4'); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + const pill = await screen.findByRole('button', { name: /errors only/i }); + fireEvent.click(pill); + + await waitFor(() => { + const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1]; + const params = new URLSearchParams((lastCall[0] as string).split('?')[1] ?? ''); + expect(params.get('hasError')).toBe('1'); + expect(params.get('page')).toBeNull(); + }); + }); + + it('disclosure button has rotating chevron and ARIA expanded state', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + const discloseButtons = await screen.findAllByRole('button', { name: /show details/i }); + const button = discloseButtons[0]; + expect(button.getAttribute('aria-expanded')).toBe('false'); + const chevron = button.querySelector('svg'); + expect(chevron?.className.baseVal ?? chevron?.getAttribute('class') ?? '').not.toContain( + 'rotate-180' + ); + + fireEvent.click(button); + + expect(button.getAttribute('aria-expanded')).toBe('true'); + const updatedChevron = button.querySelector('svg'); + const cls = updatedChevron?.className.baseVal ?? updatedChevron?.getAttribute('class') ?? ''; + expect(cls).toContain('rotate-180'); + }); + + it('detail panel shows Event Log / Job Result / Error sections when expanded', async () => { + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + const discloseButtons = await screen.findAllByRole('button', { name: /show details/i }); + fireEvent.click(discloseButtons[0]); + + expect(screen.getAllByRole('button', { name: /event log/i }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: /job result/i }).length).toBeGreaterThan(0); + expect( + screen.getAllByRole('button', { name: /^error$/i }).length + ).toBeGreaterThan(0); + }); + + it('copy button on Bull Job ID calls clipboard and shows toast', async () => { + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText: writeTextMock } }); + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() })); + render(); + + const discloseButtons = await screen.findAllByRole('button', { name: /show details/i }); + fireEvent.click(discloseButtons[0]); + + const copyButtons = screen.getAllByRole('button', { name: /copy bull job id/i }); + await act(async () => { + fireEvent.click(copyButtons[0]); + }); + + expect(writeTextMock).toHaveBeenCalledWith('bull-1'); + await waitFor(() => { + expect(screen.getByText(/Copied Bull Job ID/i)).toBeInTheDocument(); + }); + }); + + it('hides disclosure button when log has no details', async () => { + const log = makeLog({ + events: [], + errorMessage: null, + bullJobId: null, + result: null, + }); + useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([log]) })); + render(); + + await screen.findAllByText('Search Book'); + expect(screen.queryByRole('button', { name: /show details/i })).toBeNull(); + }); + + it('jump-to-page input on Enter dispatches a page change', async () => { + useSWRMock.mockImplementation( + defaultSwrImpl({ data: makeData([makeLog()], { total: 200, totalPages: 4 }) }) + ); + render(); + + const jump = await screen.findByLabelText(/jump to page/i); + fireEvent.change(jump, { target: { value: '3' } }); + fireEvent.keyDown(jump, { key: 'Enter' }); + + await waitFor(() => { + const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1]; + expect(lastCall[0]).toContain('page=3'); + }); + }); +}); + +// =========================================================================== +// buildLogsApiKey unit tests +// =========================================================================== + +describe('buildLogsApiKey', () => { + it('omits defaults so the key stays short', () => { + const key = buildLogsApiKey(DEFAULT_FILTER_STATE); + const params = new URLSearchParams(key.split('?')[1] ?? ''); + expect(params.get('page')).toBe('1'); + expect(params.get('limit')).toBe('50'); + expect(params.get('status')).toBeNull(); + expect(params.get('type')).toBeNull(); + expect(params.get('search')).toBeNull(); + expect(params.get('hasError')).toBeNull(); + }); + + it('includes every active filter', () => { + const state: LogsFilterState = { + ...DEFAULT_FILTER_STATE, + search: 'foo', + status: 'failed', + type: 'search_indexers', + dateFrom: '2024-01-01T00:00:00Z', + dateTo: '2024-01-02T00:00:00Z', + hasError: true, + userId: 'user-123', + audiobookQuery: 'Mistborn', + page: 2, + limit: 100, + }; + const params = new URLSearchParams(buildLogsApiKey(state).split('?')[1] ?? ''); + expect(params.get('search')).toBe('foo'); + expect(params.get('status')).toBe('failed'); + expect(params.get('type')).toBe('search_indexers'); + expect(params.get('dateFrom')).toBe('2024-01-01T00:00:00Z'); + expect(params.get('dateTo')).toBe('2024-01-02T00:00:00Z'); + expect(params.get('hasError')).toBe('1'); + expect(params.get('userId')).toBe('user-123'); + expect(params.get('audiobookQuery')).toBe('Mistborn'); + expect(params.get('page')).toBe('2'); + expect(params.get('limit')).toBe('100'); }); });