+// ===========================================================================
+
+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 && (
-
- )}
-
-
- {/* Desktop table — hidden on mobile */}
-
-
-
-
-
- |
- Time
- |
-
- Type
- |
-
- Status
- |
-
- Related Item
- |
-
- Duration
- |
-
- Attempts
- |
-
- Actions
- |
-
-
-
- {logs.map((log) => (
- <>
-
- |
- {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) && (
-
- )}
- |
-
- {expandedLog === log.id && (
-
- |
-
- |
-
- )}
- >
- ))}
-
-
-
-
- {logs.length === 0 && (
-
- )}
-
-
- {/* 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 */}
+
+
+
+
+
+ |
+ Time
+ |
+
+ Type
+ |
+
+ Status
+ |
+
+ Related Item
+ |
+
+ Duration
+ |
+
+ Attempts
+ |
+
+ Actions
+ |
+
+
+
+ {logs.map((log) => (
+
+ ))}
+
+
+
+
+
+ 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');
});
});