Add admin system logs UI and API support

Introduce a complete admin System Logs feature: adds frontend components (filters, date picker, active filter chips, rows, detail panel, skeletons, pagination, toolbar, user typeahead, and styles) under src/app/admin/logs/components, plus hooks (useAutoRefreshControl, useLogsUrlState, useUserSearch) and types. Add constants for job labels and log filters, wire URL-driven filters/search/date-range/hasError/user/audiobookQuery with pause-on-interact behavior and page-size options. Update API route (/api/admin/logs) to support the expanded query params and exported where-builder. Update documentation (TABLEOFCONTENTS and admin-dashboard) and add/adjust tests for the new admin logs UI and API behavior.
This commit is contained in:
kikootwo
2026-05-18 08:29:32 -04:00
parent 06195e6570
commit eef6ae3462
24 changed files with 4123 additions and 582 deletions
@@ -0,0 +1,147 @@
/**
* Component: Admin Logs — Active Filter Chips
* Documentation: documentation/admin-dashboard.md
*
* Dismissable chip strip showing every active (non-default) filter PLUS
* the search term and the Errors-only flag. Each chip is a real <button>
* with aria-label="Remove filter: <name>" and a visible × glyph.
*
* Not sticky — scrolls away with content (Zach Resolution #6).
*
* Consumes useLogsUrlState() directly; chips drive removal via removeFilter
* (with a small atomic exception for the date-range chip which clears both
* dateFrom and dateTo at once via setFilters).
*/
'use client';
import { JOB_TYPE_LABELS } from '@/lib/constants/job-labels';
import { getActivePresetId, getStatusLabel, DATE_PRESETS } from '@/lib/constants/log-filters';
import { useLogsUrlState } from '../hooks/useLogsUrlState';
import { useUserSearch } from '../hooks/useUserSearch';
export default function ActiveFilterChips() {
const { filters, setFilters, removeFilter } = useLogsUrlState();
const { findUserById } = useUserSearch();
const chips: ChipDescriptor[] = [];
if (filters.search !== '') {
chips.push({
key: 'search',
name: 'search',
label: `Search: "${filters.search}"`,
onRemove: () => removeFilter('search'),
});
}
if (filters.hasError) {
chips.push({
key: 'hasError',
name: 'errors only',
label: 'Errors only',
onRemove: () => removeFilter('hasError'),
});
}
if (filters.status !== 'all') {
chips.push({
key: 'status',
name: 'status',
label: `Status: ${getStatusLabel(filters.status)}`,
onRemove: () => removeFilter('status'),
});
}
if (filters.type !== 'all') {
const typeLabel = JOB_TYPE_LABELS[filters.type] ?? filters.type;
chips.push({
key: 'type',
name: 'job type',
label: `Type: ${typeLabel}`,
onRemove: () => removeFilter('type'),
});
}
if (filters.dateFrom !== null || filters.dateTo !== null) {
chips.push({
key: 'date',
name: 'date range',
label: `Date: ${formatDateChipLabel(filters.dateFrom, filters.dateTo)}`,
onRemove: () => setFilters({ dateFrom: null, dateTo: null }),
});
}
if (filters.userId !== null) {
const user = findUserById(filters.userId);
chips.push({
key: 'userId',
name: 'user',
label: `User: ${user?.plexUsername ?? filters.userId}`,
onRemove: () => removeFilter('userId'),
});
}
if (filters.audiobookQuery !== '') {
chips.push({
key: 'audiobookQuery',
name: 'audiobook',
label: `Book: "${filters.audiobookQuery}"`,
onRemove: () => removeFilter('audiobookQuery'),
});
}
if (chips.length === 0) return null;
return (
<div className="mb-4 flex flex-wrap gap-2" role="group" aria-label="Active filters">
{chips.map((chip) => (
<Chip key={chip.key} chip={chip} />
))}
</div>
);
}
interface ChipDescriptor {
key: string;
name: string;
label: string;
onRemove: () => void;
}
function Chip({ chip }: { chip: ChipDescriptor }) {
return (
<button
type="button"
onClick={chip.onRemove}
aria-label={`Remove filter: ${chip.name}`}
className="inline-flex items-center gap-1.5 pl-3 pr-2 py-1.5 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200 rounded-full text-sm font-medium hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors min-h-[36px]"
>
<span className="truncate max-w-[20rem]">{chip.label}</span>
<svg
className="w-3.5 h-3.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
);
}
function formatDateChipLabel(dateFrom: string | null, dateTo: string | null): string {
const presetId = getActivePresetId(dateFrom, dateTo);
if (presetId === 'custom') {
return `${formatLocal(dateFrom)} ${formatLocal(dateTo)}`;
}
const preset = DATE_PRESETS.find((p) => p.id === presetId);
return preset?.label ?? 'Custom';
}
function formatLocal(iso: string | null): string {
if (!iso) return '…';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '…';
return d.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
@@ -0,0 +1,144 @@
/**
* Component: Admin Logs — Date Range Picker
* Documentation: documentation/admin-dashboard.md
*
* Compact preset <select> over DATE_PRESETS plus an optional pair of
* <input type="datetime-local"> fields for Custom mode. Local times entered
* are converted to UTC ISO before being emitted on the wire.
*
* Pause-on-interact: registers `'logs-date-picker'` while the picker subtree
* has focus.
*/
'use client';
import { useMemo, useState } from 'react';
import {
DATE_PRESETS,
getActivePresetId,
presetToRange,
type DatePresetId,
} from '@/lib/constants/log-filters';
import { useRegisterPauseReason } from '../hooks/useAutoRefreshControl';
import { INPUT_CLASS, LABEL_CLASS } from './filter-styles';
interface DateRangePickerProps {
dateFrom: string | null;
dateTo: string | null;
onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void;
}
export default function DateRangePicker({ dateFrom, dateTo, onChange }: DateRangePickerProps) {
const [focused, setFocused] = useState(false);
useRegisterPauseReason('logs-date-picker', focused);
// Force-custom keeps the datetime-local inputs visible while the user is
// entering values — without it, derived state (both null) would snap back
// to "all_time" the moment they pick Custom but before they type anything.
const [forceCustom, setForceCustom] = useState(false);
const derivedPreset = useMemo(
() => getActivePresetId(dateFrom, dateTo),
[dateFrom, dateTo]
);
const activePreset: DatePresetId = forceCustom ? 'custom' : derivedPreset;
const showCustom = activePreset === 'custom';
const handlePresetChange = (id: DatePresetId) => {
if (id === 'custom') {
setForceCustom(true);
return;
}
setForceCustom(false);
onChange(presetToRange(id));
};
const handleCustomChange = (next: { dateFrom: string | null; dateTo: string | null }) => {
setForceCustom(true);
onChange(next);
};
return (
<div
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setFocused(false);
}
}}
>
<label className={LABEL_CLASS} htmlFor="logs-date-preset">Date Range</label>
<select
id="logs-date-preset"
value={activePreset}
onChange={(e) => handlePresetChange(e.target.value as DatePresetId)}
className={INPUT_CLASS}
>
{DATE_PRESETS.map((p) => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
{showCustom && (
<CustomDateInputs dateFrom={dateFrom} dateTo={dateTo} onChange={handleCustomChange} />
)}
</div>
);
}
function CustomDateInputs({
dateFrom,
dateTo,
onChange,
}: {
dateFrom: string | null;
dateTo: string | null;
onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void;
}) {
const fromLocal = useMemo(() => isoToLocalInputValue(dateFrom), [dateFrom]);
const toLocal = useMemo(() => isoToLocalInputValue(dateTo), [dateTo]);
return (
<div className="mt-2 space-y-2">
<div className="grid grid-cols-2 gap-2">
<input
type="datetime-local"
aria-label="Date from"
value={fromLocal}
onChange={(e) =>
onChange({ dateFrom: localInputToIso(e.target.value), dateTo })
}
className={INPUT_CLASS}
/>
<input
type="datetime-local"
aria-label="Date to"
value={toLocal}
onChange={(e) =>
onChange({ dateFrom, dateTo: localInputToIso(e.target.value) })
}
className={INPUT_CLASS}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Times are in your local timezone (sent as UTC).
</p>
</div>
);
}
function isoToLocalInputValue(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
);
}
function localInputToIso(value: string): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
@@ -0,0 +1,314 @@
/**
* Component: LogDetailPanel
* Documentation: documentation/admin-dashboard.md
*
* Three collapsible sub-sections (Event Log / Result / Error) with count badges.
* Per-event level filter. Copy-to-clipboard on each event, full event log,
* result JSON, error block, and Bull Job ID. Toast confirmations.
* Default open on desktop (`defaultOpen` prop), collapsed on mobile.
*
* NO "View related request" link — no admin request detail page exists (Zach #4).
*/
'use client';
import { useMemo, useState } from 'react';
import { useToast } from '@/components/ui/Toast';
import { JobEvent, Log } from '../types';
type Level = 'all' | 'info' | 'warn' | 'error';
// ===========================================================================
// CopyButton — extracted because used 5+ times
// ===========================================================================
interface CopyButtonProps {
text: string;
label: string;
className?: string;
/** When true, render as a compact icon-only button. */
iconOnly?: boolean;
}
function CopyButton({ text, label, className, iconOnly = false }: CopyButtonProps) {
const toast = useToast();
const handleClick = async () => {
const ok = await copyToClipboard(text);
if (ok) toast.success(`Copied ${label}`);
else toast.error('Copy unavailable on insecure connection');
};
return (
<button
type="button"
onClick={handleClick}
aria-label={`Copy ${label}`}
className={
className ??
'inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors'
}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{!iconOnly && <span>Copy</span>}
</button>
);
}
async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through to textarea fallback
}
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '0';
ta.style.left = '0';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
// ===========================================================================
// EventLine — single row in the event log
// ===========================================================================
function levelColorClass(level: string): string {
if (level === 'error') return 'text-red-400';
if (level === 'warn') return 'text-amber-400';
return 'text-emerald-400';
}
function formatEventLine(e: JobEvent): string {
const ts = (() => {
try {
return new Date(e.createdAt).toISOString().split('T')[1].split('.')[0];
} catch {
return e.createdAt;
}
})();
const meta = e.metadata && Object.keys(e.metadata).length > 0
? '\n' + JSON.stringify(e.metadata, null, 2)
: '';
return `${ts} [${e.level.toUpperCase()}] [${e.context}] ${e.message}${meta}`;
}
function EventLine({ event }: { event: JobEvent }) {
const ts = (() => {
try {
return new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
} catch {
return event.createdAt;
}
})();
return (
<div className="group relative text-gray-300 leading-relaxed pr-10">
<span className={levelColorClass(event.level)}>[{event.context}]</span>{' '}
<span className="break-words">{event.message}</span>
<span className="text-gray-500 ml-2">{ts}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
<div className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<CopyButton text={formatEventLine(event)} label="event" iconOnly />
</div>
</div>
);
}
// ===========================================================================
// Collapsible — a sub-section with title, count badge, chevron toggle
// ===========================================================================
interface CollapsibleProps {
title: string;
count?: number;
defaultOpen: boolean;
children: React.ReactNode;
headerRight?: React.ReactNode;
}
function Collapsible({ title, count, defaultOpen, children, headerRight }: CollapsibleProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div>
<div className="flex items-center justify-between gap-2 mb-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex items-center gap-1.5 min-h-[44px] py-1 text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide hover:text-gray-900 dark:hover:text-gray-100"
>
<svg
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span>{title}</span>
{typeof count === 'number' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 normal-case tracking-normal">
{count}
</span>
)}
</button>
{open && headerRight}
</div>
{open && children}
</div>
);
}
// ===========================================================================
// LogDetailPanel
// ===========================================================================
interface LogDetailPanelProps {
log: Log;
/** Default-open state for the three sub-sections. Desktop: true; Mobile: false. */
defaultOpen: boolean;
}
export function LogDetailPanel({ log, defaultOpen }: LogDetailPanelProps) {
const [level, setLevel] = useState<Level>('all');
const filteredEvents = useMemo(() => {
if (level === 'all') return log.events;
return log.events.filter((e) => e.level === level);
}, [log.events, level]);
const fullEventLog = useMemo(
() => log.events.map(formatEventLine).join('\n'),
[log.events]
);
const resultText = useMemo(
() => (log.result ? JSON.stringify(log.result, null, 2) : ''),
[log.result]
);
const hasResult = !!(log.result && Object.keys(log.result).length > 0);
return (
<div className="space-y-4">
{log.bullJobId && (
<div className="flex flex-wrap gap-1.5 items-center">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
Bull Job ID:
</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">
{log.bullJobId}
</span>
<CopyButton text={log.bullJobId} label="Bull Job ID" />
</div>
)}
{log.events.length > 0 && (
<Collapsible
title="Event Log"
count={log.events.length}
defaultOpen={defaultOpen}
headerRight={
<div className="flex items-center gap-2">
<LevelFilterPills value={level} onChange={setLevel} />
<CopyButton text={fullEventLog} label="full event log" />
</div>
}
>
{filteredEvents.length === 0 ? (
<div className="text-xs text-gray-500 dark:text-gray-400 italic">
No events at level &quot;{level}&quot;.
</div>
) : (
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
{filteredEvents.map((event) => (
<EventLine key={event.id} event={event} />
))}
</div>
)}
</Collapsible>
)}
{hasResult && (
<Collapsible
title="Job Result"
defaultOpen={defaultOpen}
headerRight={<CopyButton text={resultText} label="result" />}
>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
{resultText}
</pre>
</Collapsible>
)}
{log.errorMessage && (
<Collapsible
title="Error"
defaultOpen={defaultOpen}
headerRight={<CopyButton text={log.errorMessage} label="error" />}
>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words max-h-72 overflow-y-auto">
{log.errorMessage}
</div>
</Collapsible>
)}
</div>
);
}
// ===========================================================================
// LevelFilterPills — small group toggle
// ===========================================================================
function LevelFilterPills({
value,
onChange,
}: {
value: Level;
onChange: (next: Level) => void;
}) {
const options: { key: Level; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'info', label: 'Info' },
{ key: 'warn', label: 'Warn' },
{ key: 'error', label: 'Error' },
];
return (
<div className="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{options.map((opt) => (
<button
key={opt.key}
type="button"
onClick={() => onChange(opt.key)}
aria-pressed={value === opt.key}
className={`px-2 py-1 text-xs font-medium transition-colors ${
value === opt.key
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Component: LogRow (Desktop + Mobile wrappers + shared cell helpers)
* Documentation: documentation/admin-dashboard.md
*
* One file, one source of truth for cell logic, two layout shells:
* - <LogRow.Desktop> → renders <tr> (inside the desktop table)
* - <LogRow.Mobile> → renders <div> (inside the mobile card list)
* Cell helpers (<RowTime>, <RowType>, <RowStatus>, etc.) are pure and used
* by both shells. No duplicated logic; layout split is just JSX containers.
*
* Disclosure: real <button> with rotating chevron. NOT a "Show Details"
* text link, NOT a whole-row click. 44×44 min touch target.
*/
'use client';
import { useEffect, useState } from 'react';
import { JOB_TYPE_LABELS } from '@/lib/constants/job-labels';
import { Log, logHasDetails } from '../types';
import { LogDetailPanel } from './LogDetailPanel';
import { useAutoRefreshControl } from '../hooks/useAutoRefreshControl';
// ===========================================================================
// Formatters
// ===========================================================================
function formatJobType(type: string): string {
return (
JOB_TYPE_LABELS[type] ??
type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
);
}
function formatDuration(startedAt: string | null, completedAt: string | null): string {
if (!startedAt) return 'N/A';
if (!completedAt) return 'Running…';
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}h ${m % 60}m`;
if (m > 0) return `${m}m ${s % 60}s`;
return `${s}s`;
}
function formatRelativeTime(iso: string, now: number): string {
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const elapsed = Math.max(0, now - t);
const s = Math.floor(elapsed / 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);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
function formatAbsoluteTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
// ===========================================================================
// Status badge (lifted from previous logs page; same visual)
// ===========================================================================
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { dot: string; text: string; bg: string }> = {
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' };
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
// ===========================================================================
// Shared cell helpers — used by BOTH desktop tr and mobile div
// ===========================================================================
function RowTime({ log, now }: { log: Log; now: number }) {
return (
<span
className="text-sm text-gray-900 dark:text-gray-100"
title={formatAbsoluteTime(log.createdAt)}
>
{formatRelativeTime(log.createdAt, now)}
</span>
);
}
function RowType({ log }: { log: Log }) {
return (
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatJobType(log.type)}
</span>
);
}
function RowRelatedItem({ log }: { log: Log }) {
if (!log.request?.audiobook) {
return <span className="text-sm text-gray-500 dark:text-gray-400">System job</span>;
}
return (
<div className="text-sm">
<div className="font-medium text-gray-900 dark:text-gray-100">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400">
by {log.request.audiobook.author}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
User: {log.request.user.plexUsername}
</div>
</div>
);
}
function RowDuration({ log }: { log: Log }) {
return (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formatDuration(log.startedAt, log.completedAt)}
</span>
);
}
function RowAttempts({ log }: { log: Log }) {
return (
<span className="text-sm text-gray-500 dark:text-gray-400">
{log.attempts}/{log.maxAttempts}
</span>
);
}
interface DisclosureButtonProps {
log: Log;
expanded: boolean;
detailPanelId: string;
onToggle: () => void;
}
function RowDisclosureButton({ log, expanded, detailPanelId, onToggle }: DisclosureButtonProps) {
if (!logHasDetails(log)) return null;
return (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={detailPanelId}
aria-label={expanded ? 'Hide details' : 'Show details'}
className="inline-flex items-center justify-center min-w-[44px] min-h-[44px] w-11 h-11 rounded-lg text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<svg
className={`w-5 h-5 transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
}
// ===========================================================================
// Shared expansion + clock state hook
// ===========================================================================
function useRowState(log: Log) {
const [expanded, setExpanded] = useState(false);
const { register, unregister } = useAutoRefreshControl();
// While this row is expanded, register a pause reason.
useEffect(() => {
if (!expanded) return;
const reason = `row-expanded:${log.id}`;
register(reason);
return () => unregister(reason);
}, [expanded, log.id, register, unregister]);
const detailPanelId = `log-detail-${log.id}`;
const toggle = () => setExpanded((v) => !v);
return { expanded, toggle, detailPanelId };
}
function useNowTick(intervalMs = 30_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
// ===========================================================================
// Desktop wrapper — <tr>
// ===========================================================================
interface RowProps {
log: Log;
}
function LogRowDesktop({ log }: RowProps) {
const { expanded, toggle, detailPanelId } = useRowState(log);
const now = useNowTick();
return (
<>
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<RowTime log={log} now={now} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<RowType log={log} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={log.status} />
</td>
<td className="px-6 py-4">
<RowRelatedItem log={log} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<RowDuration log={log} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<RowAttempts log={log} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<RowDisclosureButton
log={log}
expanded={expanded}
detailPanelId={detailPanelId}
onToggle={toggle}
/>
</td>
</tr>
{expanded && (
<tr>
<td colSpan={7} id={detailPanelId} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<LogDetailPanel log={log} defaultOpen={true} />
</td>
</tr>
)}
</>
);
}
// ===========================================================================
// Mobile wrapper — <div>
// ===========================================================================
function LogRowMobile({ log }: RowProps) {
const { expanded, toggle, detailPanelId } = useRowState(log);
const now = useNowTick();
const hasDetails = logHasDetails(log);
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
<RowType log={log} />
</div>
<StatusBadge status={log.status} />
</div>
<div className="mb-2">
<RowRelatedItem log={log} />
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<RowTime log={log} now={now} />
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
</div>
</div>
{hasDetails && (
<>
<div className="flex items-center justify-between px-2 py-1 border-t border-gray-100 dark:border-gray-700/60">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 px-2">
{expanded ? 'Hide details' : 'Show details'}
</span>
<RowDisclosureButton
log={log}
expanded={expanded}
detailPanelId={detailPanelId}
onToggle={toggle}
/>
</div>
{expanded && (
<div
id={detailPanelId}
className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60"
>
<LogDetailPanel log={log} defaultOpen={false} />
</div>
)}
</>
)}
</div>
);
}
// ===========================================================================
// Public exports
// ===========================================================================
export const LogRow = {
Desktop: LogRowDesktop,
Mobile: LogRowMobile,
};
@@ -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 */}
<div className="space-y-3 sm:hidden" data-testid="log-skeleton-mobile">
{items.map((i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 animate-pulse"
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-5 w-20 rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-3 w-48 rounded bg-gray-200 dark:bg-gray-700 mb-1.5" />
<div className="h-3 w-36 rounded bg-gray-200 dark:bg-gray-700 mb-3" />
<div className="flex gap-4">
<div className="h-3 w-14 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-3 w-16 rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
))}
</div>
{/* Desktop table skeletons */}
<div
className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"
data-testid="log-skeleton-desktop"
>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{items.map((i) => (
<tr key={i} className="animate-pulse">
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-5 w-20 rounded-full bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4">
<div className="h-4 w-48 rounded bg-gray-200 dark:bg-gray-700 mb-1" />
<div className="h-3 w-32 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-3 w-12 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-3 w-10 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="h-8 w-8 rounded-lg bg-gray-200 dark:bg-gray-700 ml-auto" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
@@ -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 (
<div className="mb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
<StatusDropdown
value={filters.status}
onChange={(value) => setFilters({ status: value })}
/>
<JobTypeDropdown
value={filters.type}
onChange={(value) => setFilters({ type: value })}
/>
<DateRangePicker
dateFrom={filters.dateFrom}
dateTo={filters.dateTo}
onChange={(next) => setFilters(next)}
/>
<UserTypeahead
userId={filters.userId}
onChange={(id) => setFilters({ userId: id })}
/>
<AudiobookInput
value={filters.audiobookQuery}
onChange={(value) => setFilters({ audiobookQuery: value })}
/>
</div>
{showClearAll && (
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={clearAll}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors min-h-[44px]"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear all filters
</button>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Status dropdown
// ---------------------------------------------------------------------------
function StatusDropdown({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [focused, setFocused] = useState(false);
useRegisterPauseReason('logs-status-dropdown', focused);
return (
<div>
<label className={LABEL_CLASS} htmlFor="logs-status-filter">Status</label>
<select
id="logs-status-filter"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={INPUT_CLASS}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
// ---------------------------------------------------------------------------
// Job-type dropdown
// ---------------------------------------------------------------------------
function JobTypeDropdown({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [focused, setFocused] = useState(false);
useRegisterPauseReason('logs-type-dropdown', focused);
return (
<div>
<label className={LABEL_CLASS} htmlFor="logs-type-filter">Job Type</label>
<select
id="logs-type-filter"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={INPUT_CLASS}
>
<option value="all">All Types</option>
{Object.entries(JOB_TYPE_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div>
<label className={LABEL_CLASS} htmlFor="logs-audiobook-input">Audiobook</label>
<input
id="logs-audiobook-input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="Title or author"
className={INPUT_CLASS}
/>
</div>
);
}
@@ -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 (
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Summary + limit */}
<div className="flex flex-wrap items-center gap-3 sm:gap-4 text-sm text-gray-600 dark:text-gray-400">
<span data-testid="logs-pagination-summary">
Page <span className="font-medium text-gray-900 dark:text-gray-100">{page}</span> of{' '}
<span className="font-medium text-gray-900 dark:text-gray-100">{Math.max(1, totalPages)}</span>
{' · '}
<span className="font-medium text-gray-900 dark:text-gray-100">
{total.toLocaleString()}
</span>{' '}
{total === 1 ? 'log' : 'logs'}
</span>
<label className="flex items-center gap-2 text-sm">
<span className="text-gray-600 dark:text-gray-400">Per page</span>
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value) as ValidLimit)}
onFocus={() => setLimitFocused(true)}
onBlur={() => setLimitFocused(false)}
className="min-h-[44px] px-3 py-2 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 text-sm"
aria-label="Page size"
>
{VALID_LIMITS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
</div>
{/* Nav controls */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Previous page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="hidden sm:inline">Previous</span>
</button>
<label className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400 sr-only sm:not-sr-only">
Go to
</span>
<input
type="number"
min={1}
max={Math.max(1, totalPages)}
value={jumpValue}
onChange={(e) => setJumpValue(e.target.value)}
onBlur={submitJump}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitJump();
}
}}
className="min-h-[44px] w-20 px-3 py-2 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 text-sm text-center"
aria-label="Jump to page"
/>
</label>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Next page"
>
<span className="hidden sm:inline">Next</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
}
@@ -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 (
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
{/* Row 1: title + back link */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
System Logs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
View background jobs and system activity
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 min-h-[44px] px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
{/* Row 2: errors-only pill + live indicator + refresh + auto-toggle */}
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
type="button"
onClick={() => {
if (errorsOnlyActive) removeFilter('hasError');
else setFilters({ hasError: true });
}}
aria-pressed={errorsOnlyActive}
className={`inline-flex items-center gap-1.5 min-h-[44px] px-3.5 py-2 rounded-full text-sm font-medium transition-colors ${
errorsOnlyActive
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/40'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
Errors only
</button>
<div
className="inline-flex items-center gap-1.5 min-h-[44px] px-3 py-2 rounded-full text-sm font-medium bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
title={indicatorTitle}
aria-label={indicatorTitle}
data-testid="logs-live-indicator"
data-state={isPaused ? 'paused' : 'running'}
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isRunning ? 'bg-green-500 animate-pulse' : 'bg-amber-500'
}`}
/>
<span className="text-gray-700 dark:text-gray-300">{indicatorText}</span>
</div>
<button
type="button"
onClick={manualRefresh}
className="inline-flex items-center gap-1.5 min-h-[44px] px-3.5 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
aria-label="Refresh now"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span className="hidden sm:inline">Refresh now</span>
<span className="sm:hidden">Refresh</span>
</button>
<label className="inline-flex items-center gap-2 ml-auto cursor-pointer">
<span className="text-sm text-gray-600 dark:text-gray-400">Auto-refresh</span>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Auto-refresh"
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</label>
</div>
{/* Row 3: search input */}
<div className="mt-3 relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
</span>
<input
type="search"
value={searchInput}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => {
setSearchInput('');
removeFilter('search');
}}
aria-label="Clear search"
className="absolute inset-y-0 right-2 my-auto inline-flex items-center justify-center w-8 h-8 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
);
}
@@ -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<string>(selected?.plexUsername ?? '');
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState<number>(-1);
const containerRef = useRef<HTMLDivElement | null>(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<HTMLInputElement>) => {
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 (
<div ref={containerRef} className="relative">
<label className={LABEL_CLASS} htmlFor="logs-user-typeahead">User</label>
<div className="relative">
<input
id="logs-user-typeahead"
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
value={query}
placeholder={isLoading ? 'Loading users…' : 'Search by plex username'}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActiveIdx(-1);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
className={`${INPUT_CLASS} pr-9`}
/>
{query && (
<button
type="button"
onClick={handleClear}
aria-label="Clear user filter"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700/60"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{open && suggestions.length > 0 && (
<ul
id={listboxId}
role="listbox"
className="absolute z-20 mt-1 w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg"
>
{suggestions.map((user, idx) => {
const isActive = idx === activeIdx;
return (
<li
key={user.id}
role="option"
aria-selected={isActive}
className={`px-3 py-2 text-sm cursor-pointer ${
isActive
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-900 dark:text-blue-200'
: 'text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700/60'
}`}
onMouseDown={(e) => {
// onMouseDown so the input's blur doesn't fire first and close us.
e.preventDefault();
handleSelect(user);
}}
onMouseEnter={() => setActiveIdx(idx)}
>
<span className="font-medium">{user.plexUsername}</span>
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">{user.role}</span>
</li>
);
})}
</ul>
)}
{open && !isLoading && suggestions.length === 0 && query.trim() !== '' && (
<div className="absolute z-20 mt-1 w-full px-3 py-2 text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg">
No users match &ldquo;{query}&rdquo;
</div>
)}
</div>
);
}
@@ -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';
@@ -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<unknown> | void) | null) => void;
/** Setter the consumer uses to broadcast "we just got fresh data at <Date>". */
setLastUpdatedAt: (ts: number) => void;
/** Timestamp of last successful refresh (ms since epoch); 0 if never. */
lastUpdatedAt: number;
}
const AutoRefreshContext = createContext<AutoRefreshControl | null>(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 <AutoRefreshControlProvider>'
);
}
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<Set<string>>(new Set());
const [visibleReasons, setVisibleReasons] = useState<string[]>([]);
const exitDebounceRef = useRef<ReturnType<typeof setTimeout> | 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<unknown> | void) | null>(null);
const setMutate = useCallback((fn: (() => Promise<unknown> | 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]);
}
+278
View File
@@ -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<LogsFilterState>) => 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<ReturnType<typeof setTimeout> | 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<LogsFilterState>) => {
// 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,
};
}
+88
View File
@@ -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<UsersApiResponse>(
USERS_URL,
authenticatedFetcher,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
dedupingInterval: DEDUPING_INTERVAL_MS,
}
);
const users = useMemo<UserSearchUser[]>(() => 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,
};
}
+215 -468
View File
@@ -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<string, { dot: string; text: string; bg: string }> = {
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 (
<div className="text-center py-16">
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
No background jobs have run yet.
</p>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
New jobs will appear here as they start.
</p>
</div>
);
}
if (kind === 'search-no-match') {
return (
<div className="text-center py-16">
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
No matches for &ldquo;{searchValue}&rdquo;.
</p>
<button
type="button"
onClick={onClearSearch}
aria-label="Clear search and show all logs"
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
Clear search
</button>
</div>
);
}
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
function LogDetails({ log }: { log: Log }) {
return (
<div className="space-y-4">
{log.bullJobId && (
<div className="flex flex-wrap gap-1.5 items-baseline">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Bull Job ID:</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">{log.bullJobId}</span>
</div>
)}
{log.events.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Event Log
</h4>
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
{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 (
<div key={event.id} className="text-gray-300 leading-relaxed">
<span className={levelColor}>[{event.context}]</span>
{' '}
<span className="break-words">{event.message}</span>
<span className="text-gray-500 ml-2">{timestamp}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
);
})}
</div>
</div>
)}
{log.result && Object.keys(log.result).length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Job Result
</h4>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
{JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{log.errorMessage && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Error
</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words">
{log.errorMessage}
</div>
</div>
)}
<div className="text-center py-16">
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
No logs match your current filters.
</p>
<button
type="button"
onClick={onClearFilters}
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
Clear filters
</button>
</div>
);
}
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<string>(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<string | null>(null);
const { data, error, mutate } = useSWR<LogsData>(key, authenticatedFetcher, {
refreshInterval: effectiveInterval,
keepPreviousData: true,
});
const { data, error } = useSWR<LogsData>(
`/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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
);
}
// 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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'}
</p>
</div>
</div>
</div>
);
}
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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<LogsToolbar />
{/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
System Logs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
View background jobs and system activity
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
</div>
{/* Filter dropdowns + chip strip — owned by ben-filters, rendered here. */}
<LogsFilters />
<ActiveFilterChips />
{/* Filters — full-width stacked on mobile */}
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Status
</label>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="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 text-sm"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="delayed">Delayed</option>
<option value="stuck">Stuck</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Job Type
</label>
<select
value={typeFilter}
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
className="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 text-sm"
>
<option value="all">All Types</option>
<option value="search_indexers">Search Indexers</option>
<option value="download_torrent">Download Torrent</option>
<option value="monitor_download">Monitor Download</option>
<option value="organize_files">Organize Files</option>
<option value="scan_plex">Library Scan</option>
<option value="match_plex">Library Match</option>
<option value="plex_library_scan">Library Scan (Scheduled)</option>
<option value="plex_recently_added_check">Recently Added Check</option>
<option value="audible_refresh">Audible Refresh</option>
<option value="retry_missing_torrents">Retry Missing Torrents</option>
<option value="retry_failed_imports">Retry Failed Imports</option>
<option value="cleanup_seeded_torrents">Cleanup Seeded Torrents</option>
<option value="monitor_rss_feeds">Monitor RSS Feeds</option>
</select>
</div>
</div>
{/* Mobile card list — hidden on sm+ */}
<div className="space-y-3 sm:hidden">
{logs.map((log) => (
<div
key={log.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{formatType(log.type)}
</div>
<StatusBadge status={log.status} />
</div>
{/* Related item */}
{log.request?.audiobook ? (
<div className="text-sm mb-2">
<div className="text-gray-700 dark:text-gray-300 font-medium leading-snug">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">
by {log.request.audiobook.author} &middot; {log.request.user.plexUsername}
</div>
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">System job</div>
)}
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDateShort(log.createdAt)}</span>
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
</div>
</div>
{/* Expandable details */}
{hasDetails(log) && (
<>
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="w-full flex items-center justify-between px-4 py-2.5 border-t border-gray-100 dark:border-gray-700/60 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
>
<span>{expandedLog === log.id ? 'Hide Details' : 'Show Details'}</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${expandedLog === log.id ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedLog === log.id && (
<div className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60">
<LogDetails log={log} />
</div>
)}
</>
)}
</div>
))}
{logs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
</div>
)}
</div>
{/* Desktop table — hidden on mobile */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Related Item
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Attempts
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{logs.map((log) => (
<>
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{new Date(log.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatType(log.type)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={log.status} />
</td>
<td className="px-6 py-4">
{log.request?.audiobook ? (
<div className="text-sm">
<div className="font-medium text-gray-900 dark:text-gray-100">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400">
by {log.request.audiobook.author}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
User: {log.request.user.plexUsername}
</div>
</div>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">System job</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatDuration(log.startedAt, log.completedAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{log.attempts}/{log.maxAttempts}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{hasDetails(log) && (
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
{expandedLog === log.id ? 'Hide Details' : 'Show Details'}
</button>
)}
</td>
</tr>
{expandedLog === log.id && (
<tr>
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<LogDetails log={log} />
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
{logs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
</div>
)}
</div>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
Page {pagination.page} of {pagination.totalPages}
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
</div>
<div className="flex gap-2 order-1 sm:order-2">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === pagination.totalPages}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error Loading Logs
</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'}
</p>
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About System Logs
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> Logs are automatically refreshed every 10 seconds</li>
<li> Tap &quot;Show Details&quot; to view event logs, job results, and errors</li>
<li> Event logs show all internal operations with timestamps</li>
<li> Jobs are retried automatically based on their max attempts setting</li>
<li> Use filters to find specific job types or statuses</li>
</ul>
</div>
{showSkeleton ? (
<LogSkeleton />
) : emptyKind ? (
<EmptyState
kind={emptyKind}
onClearFilters={clearAll}
onClearSearch={() => setFilters({ search: '' })}
searchValue={filters.search}
/>
) : (
<>
{/* Mobile cards */}
<div className="space-y-3 sm:hidden">
{logs.map((log) => (
<LogRow.Mobile key={log.id} log={log} />
))}
</div>
{/* Desktop table */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Related Item
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Attempts
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{logs.map((log) => (
<LogRow.Desktop key={log.id} log={log} />
))}
</tbody>
</table>
</div>
</div>
<LogsPagination
pagination={pagination}
onPageChange={(page) => setFilters({ page })}
onLimitChange={(limit: ValidLimit) => setFilters({ limit })}
/>
</>
)}
</div>
</div>
);
}
export default function AdminLogsPage() {
return (
<Suspense fallback={null}>
<ToastProvider>
<AutoRefreshControlProvider>
<AdminLogsPageContent />
</AutoRefreshControlProvider>
</ToastProvider>
</Suspense>
);
}
+200
View File
@@ -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<string, unknown> | 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<string, unknown> | 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';
}
+133 -13
View File
@@ -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<string, any> {
const where: Record<string, any> = {};
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,
+26
View File
@@ -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<string, string> = {
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',
};
+130
View File
@@ -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<Record<LogStatus, string>> = {
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);
}