mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -6,7 +6,6 @@
|
||||
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
|
||||
- **API tokens (allowlist, write capability, /api-docs)** → [backend/services/api-tokens.md](backend/services/api-tokens.md)
|
||||
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
||||
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||
- **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
|
||||
@@ -60,7 +59,6 @@
|
||||
- **Ebook delete behavior (files only, torrents seed)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior)
|
||||
- **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||
- **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed)
|
||||
- **Auto-search behavior toggle (skip unreleased books)** → [settings-pages.md](settings-pages.md#auto-search-behavior-indexers-tab)
|
||||
|
||||
## Automation Pipeline
|
||||
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
||||
@@ -70,7 +68,6 @@
|
||||
- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
|
||||
- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md)
|
||||
- **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md)
|
||||
- **Plex-compatible format coercion (.mp4 → .m4b)** → [phase3/file-organization.md](phase3/file-organization.md#plex-format-coercion)
|
||||
- **Chapter merging (auto-merge to M4B)** → [features/chapter-merging.md](features/chapter-merging.md)
|
||||
|
||||
## Background Jobs
|
||||
@@ -105,6 +102,7 @@
|
||||
|
||||
## Admin Features
|
||||
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
|
||||
- **System logs (filters, search, pagination, /api/admin/logs)** → [admin-dashboard.md](admin-dashboard.md)
|
||||
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
|
||||
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
|
||||
|
||||
@@ -57,9 +57,18 @@ Comprehensive overview of system metrics, active requests, download monitoring,
|
||||
- Update global auto-approve setting (boolean)
|
||||
|
||||
**GET /api/admin/logs**
|
||||
- Query params: page, limit, status, type
|
||||
- Returns: Job logs with request/audiobook/user details, pagination info
|
||||
- Filters: status (all/pending/active/completed/failed/delayed/stuck), type (all job types)
|
||||
- Query params: page, limit, status, type, search, dateFrom, dateTo, hasError, userId, audiobookQuery
|
||||
- limit: one of 25/50/100 (default 50; invalid values clamp to 50)
|
||||
- status: 'all' or one of pending/active/completed/failed/delayed/stuck
|
||||
- type: 'all' or any job type key
|
||||
- dateFrom / dateTo: ISO UTC strings; invalid dates silently dropped
|
||||
- hasError: 'true' or '1' → `status in (failed, stuck) OR errorMessage IS NOT NULL`
|
||||
- userId: uuid → filters via `request.userId`
|
||||
- audiobookQuery: free text → OR-contains (case-insensitive) on `request.audiobook.{title,author}`
|
||||
- search: free text → 6-column OR: bullJobId (startsWith, case-sensitive), errorMessage (contains-i), events.some.message (contains-i), request.audiobook.title/author (contains-i), request.user.plexUsername (contains-i)
|
||||
- hasError + search combine under top-level `AND`; other filters compose via AND on `where`
|
||||
- Where-builder: exported `buildLogsWhere(params)` in route file (pure, testable)
|
||||
- Returns: `{ logs, pagination: { page, limit, total, totalPages } }`
|
||||
|
||||
## Request Management Features
|
||||
|
||||
@@ -112,15 +121,21 @@ Comprehensive overview of system metrics, active requests, download monitoring,
|
||||
|
||||
## System Logs Features
|
||||
|
||||
- Real-time job monitoring (10s refresh)
|
||||
- Filter by status (pending/active/completed/failed/delayed/stuck)
|
||||
- Filter by job type (search_indexers/monitor_download/organize_files/scan_plex/match_plex)
|
||||
- Real-time job monitoring (10s SWR refresh; pauses on interact)
|
||||
- **Filter row (5 pickers):** Status · Job Type · Date Range · User typeahead · Audiobook free-text
|
||||
- Status: dropdown over VALID_STATUSES (from `src/app/admin/logs/types.ts`); labels via `STATUS_OPTIONS` in `src/lib/constants/log-filters.ts`
|
||||
- Job Type: dropdown over `JOB_TYPE_LABELS` insertion order (`src/lib/constants/job-labels.ts`)
|
||||
- Date Range: presets (Last hour / 24h / 7d / 30d / Custom / All time) — default = Last 7 days (Zach #1); Custom uses `<input type="datetime-local">` rendered as local time, wired as UTC ISO
|
||||
- User: typeahead via `useUserSearch` (fetch-once from `/api/admin/users`, SWR-cached, in-memory filter, max 10 suggestions); selection sets `userId = User.id`
|
||||
- Audiobook: free-text → server-side OR-contains on title/author (Zach #4 — no picker)
|
||||
- **Active filter chips:** dismissable `<button aria-label="Remove filter: X">` strip; NOT sticky (Zach #6 — scrolls with content). Errors-only renders as a chip when active.
|
||||
- **Clear all filters:** visible only when ≥1 filter or the search input is non-default
|
||||
- **Pause-on-interact reasons (registered to `useAutoRefreshControl`):**
|
||||
- `logs-status-dropdown`, `logs-type-dropdown`, `logs-date-picker`, `logs-user-typeahead`, `logs-book-input`
|
||||
- **URL = source of truth** via `useLogsUrlState` (`src/app/admin/logs/hooks/`); param names exported as `LOG_PARAMS`; same names used by `/api/admin/logs`
|
||||
- Shows related audiobook/user for request jobs
|
||||
- Expandable error messages
|
||||
- Duration calculation
|
||||
- Attempt tracking (current/max)
|
||||
- Pagination (50 logs per page)
|
||||
- Shows Bull job ID
|
||||
- Expandable error messages, duration calc, attempt tracking, Bull job ID
|
||||
- Pagination: page-size selector (25 / 50 / 100), default 50
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -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 "{level}".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{query}”
|
||||
</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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 “{searchValue}”.
|
||||
</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} · {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 "Show Details" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -21,6 +21,18 @@ vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
async function callRoute(query: string = '') {
|
||||
prismaMock.job.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.job.count.mockResolvedValueOnce(0);
|
||||
const { GET } = await import('@/app/api/admin/logs/route');
|
||||
const url = `http://localhost/api/admin/logs${query ? `?${query}` : ''}`;
|
||||
const response = await GET({ url } as any);
|
||||
const payload = await response.json();
|
||||
const findManyArgs = prismaMock.job.findMany.mock.calls[0][0];
|
||||
const countArgs = prismaMock.job.count.mock.calls[0][0];
|
||||
return { response, payload, findManyArgs, countArgs };
|
||||
}
|
||||
|
||||
describe('Admin logs route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -34,12 +46,209 @@ describe('Admin logs route', () => {
|
||||
prismaMock.job.count.mockResolvedValueOnce(1);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/logs/route');
|
||||
const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=10' } as any);
|
||||
const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=25' } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.logs).toHaveLength(1);
|
||||
expect(payload.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
describe('where composition', () => {
|
||||
it('builds empty where when no filters provided', async () => {
|
||||
const { findManyArgs } = await callRoute();
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('applies status filter only when not "all"', async () => {
|
||||
const { findManyArgs } = await callRoute('status=failed');
|
||||
expect(findManyArgs.where).toEqual({ status: 'failed' });
|
||||
});
|
||||
|
||||
it('skips status when value is "all"', async () => {
|
||||
const { findManyArgs } = await callRoute('status=all');
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('applies type filter only when not "all"', async () => {
|
||||
const { findManyArgs } = await callRoute('type=scan_plex');
|
||||
expect(findManyArgs.where).toEqual({ type: 'scan_plex' });
|
||||
});
|
||||
|
||||
it('applies dateFrom and dateTo as createdAt range', async () => {
|
||||
const { findManyArgs } = await callRoute(
|
||||
'dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z'
|
||||
);
|
||||
expect(findManyArgs.where.createdAt).toEqual({
|
||||
gte: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lte: new Date('2026-02-01T00:00:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('silently drops invalid date strings', async () => {
|
||||
const { findManyArgs } = await callRoute('dateFrom=not-a-date&dateTo=also-not-a-date');
|
||||
expect(findManyArgs.where.createdAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies hasError=true as OR of failed/stuck status or non-null errorMessage', async () => {
|
||||
const { findManyArgs } = await callRoute('hasError=true');
|
||||
expect(findManyArgs.where.OR).toEqual([
|
||||
{ status: { in: ['failed', 'stuck'] } },
|
||||
{ errorMessage: { not: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('also accepts hasError=1 as truthy', async () => {
|
||||
const { findManyArgs } = await callRoute('hasError=1');
|
||||
expect(findManyArgs.where.OR).toEqual([
|
||||
{ status: { in: ['failed', 'stuck'] } },
|
||||
{ errorMessage: { not: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats hasError=false as no errors filter', async () => {
|
||||
const { findManyArgs } = await callRoute('hasError=false');
|
||||
expect(findManyArgs.where.OR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies userId filter via request.is.userId', async () => {
|
||||
const { findManyArgs } = await callRoute('userId=user-abc-123');
|
||||
expect(findManyArgs.where.request).toEqual({ is: { userId: 'user-abc-123' } });
|
||||
});
|
||||
|
||||
it('applies audiobookQuery as OR-contains on audiobook title/author', async () => {
|
||||
const { findManyArgs } = await callRoute('audiobookQuery=Sanderson');
|
||||
expect(findManyArgs.where.request).toEqual({
|
||||
is: {
|
||||
audiobook: {
|
||||
is: {
|
||||
OR: [
|
||||
{ title: { contains: 'Sanderson', mode: 'insensitive' } },
|
||||
{ author: { contains: 'Sanderson', mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('search applies six-column OR with bullJobId startsWith (case-sensitive)', async () => {
|
||||
const { findManyArgs } = await callRoute('search=abc123');
|
||||
const or = findManyArgs.where.OR;
|
||||
expect(Array.isArray(or)).toBe(true);
|
||||
expect(or).toHaveLength(6);
|
||||
expect(or[0]).toEqual({ bullJobId: { startsWith: 'abc123' } });
|
||||
expect(or[1]).toEqual({ errorMessage: { contains: 'abc123', mode: 'insensitive' } });
|
||||
});
|
||||
|
||||
it('search includes events.some.message clause for event-text search', async () => {
|
||||
const { findManyArgs } = await callRoute('search=timeout');
|
||||
const hasEventClause = findManyArgs.where.OR.some(
|
||||
(clause: any) =>
|
||||
clause.events?.some?.message?.contains === 'timeout' &&
|
||||
clause.events?.some?.message?.mode === 'insensitive'
|
||||
);
|
||||
expect(hasEventClause).toBe(true);
|
||||
});
|
||||
|
||||
it('search includes audiobook title/author and plexUsername clauses', async () => {
|
||||
const { findManyArgs } = await callRoute('search=foo');
|
||||
const or = findManyArgs.where.OR;
|
||||
const findRequestClause = (path: (clause: any) => any) =>
|
||||
or.find((clause: any) => path(clause) === 'foo');
|
||||
expect(findRequestClause((c: any) => c.request?.is?.audiobook?.is?.title?.contains)).toBeTruthy();
|
||||
expect(findRequestClause((c: any) => c.request?.is?.audiobook?.is?.author?.contains)).toBeTruthy();
|
||||
expect(findRequestClause((c: any) => c.request?.is?.user?.is?.plexUsername?.contains)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats whitespace-only search as no search', async () => {
|
||||
const { findManyArgs } = await callRoute('search=%20%20%20');
|
||||
expect(findManyArgs.where.OR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats whitespace-only audiobookQuery as no filter', async () => {
|
||||
const { findManyArgs } = await callRoute('audiobookQuery=%20');
|
||||
expect(findManyArgs.where.request).toBeUndefined();
|
||||
});
|
||||
|
||||
it('combines hasError and search under top-level AND wrapper', async () => {
|
||||
const { findManyArgs } = await callRoute('hasError=true&search=oom');
|
||||
expect(findManyArgs.where.AND).toBeDefined();
|
||||
expect(findManyArgs.where.AND).toHaveLength(2);
|
||||
expect(findManyArgs.where.OR).toBeUndefined();
|
||||
const orClauses = findManyArgs.where.AND.map((c: any) => c.OR);
|
||||
expect(orClauses[0]).toEqual([
|
||||
{ status: { in: ['failed', 'stuck'] } },
|
||||
{ errorMessage: { not: null } },
|
||||
]);
|
||||
expect(Array.isArray(orClauses[1])).toBe(true);
|
||||
expect(orClauses[1]).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('combines all filters together', async () => {
|
||||
const { findManyArgs } = await callRoute(
|
||||
'status=failed&type=scan_plex&dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z&userId=user-1&audiobookQuery=Way%20of%20Kings&hasError=true&search=disk'
|
||||
);
|
||||
const where = findManyArgs.where;
|
||||
expect(where.status).toBe('failed');
|
||||
expect(where.type).toBe('scan_plex');
|
||||
expect(where.createdAt.gte).toEqual(new Date('2026-01-01T00:00:00.000Z'));
|
||||
expect(where.createdAt.lte).toEqual(new Date('2026-02-01T00:00:00.000Z'));
|
||||
expect(where.request.is.userId).toBe('user-1');
|
||||
expect(where.request.is.audiobook.is.OR).toHaveLength(2);
|
||||
expect(where.AND).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses identical where for findMany and count', async () => {
|
||||
const { findManyArgs, countArgs } = await callRoute('status=failed&hasError=true');
|
||||
expect(countArgs.where).toEqual(findManyArgs.where);
|
||||
});
|
||||
});
|
||||
|
||||
describe('limit clamp', () => {
|
||||
const cases: Array<[string | null, number]> = [
|
||||
['25', 25],
|
||||
['50', 50],
|
||||
['100', 100],
|
||||
['24', 50],
|
||||
['75', 50],
|
||||
['101', 50],
|
||||
['abc', 50],
|
||||
[null, 50],
|
||||
];
|
||||
|
||||
for (const [raw, expected] of cases) {
|
||||
it(`limit=${raw} → take ${expected}`, async () => {
|
||||
const query = raw === null ? '' : `limit=${raw}`;
|
||||
const { findManyArgs, payload } = await callRoute(query);
|
||||
expect(findManyArgs.take).toBe(expected);
|
||||
expect(payload.pagination.limit).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('pagination math', () => {
|
||||
it('page=2 with limit=50 and total=75 returns totalPages=2 and skip=50', async () => {
|
||||
prismaMock.job.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.job.count.mockResolvedValueOnce(75);
|
||||
const { GET } = await import('@/app/api/admin/logs/route');
|
||||
const response = await GET({
|
||||
url: 'http://localhost/api/admin/logs?page=2&limit=50',
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
const findManyArgs = prismaMock.job.findMany.mock.calls[0][0];
|
||||
|
||||
expect(findManyArgs.skip).toBe(50);
|
||||
expect(findManyArgs.take).toBe(50);
|
||||
expect(payload.pagination.page).toBe(2);
|
||||
expect(payload.pagination.limit).toBe(50);
|
||||
expect(payload.pagination.total).toBe(75);
|
||||
expect(payload.pagination.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
it('coerces invalid page to 1', async () => {
|
||||
const { findManyArgs, payload } = await callRoute('page=-3');
|
||||
expect(findManyArgs.skip).toBe(0);
|
||||
expect(payload.pagination.page).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Component: Admin Logs — ActiveFilterChips Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ActiveFilterChips from '@/app/admin/logs/components/ActiveFilterChips';
|
||||
import { DEFAULT_FILTER_STATE, type LogsFilterState } from '@/app/admin/logs/types';
|
||||
|
||||
const setFiltersMock = vi.fn();
|
||||
const removeFilterMock = vi.fn();
|
||||
const clearAllMock = vi.fn();
|
||||
let mockFilters: LogsFilterState = { ...DEFAULT_FILTER_STATE };
|
||||
|
||||
vi.mock('@/app/admin/logs/hooks/useLogsUrlState', () => ({
|
||||
useLogsUrlState: () => ({
|
||||
filters: mockFilters,
|
||||
setFilters: setFiltersMock,
|
||||
setSearchInput: vi.fn(),
|
||||
searchInput: mockFilters.search,
|
||||
clearAll: clearAllMock,
|
||||
removeFilter: removeFilterMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const findUserByIdMock = vi.fn();
|
||||
vi.mock('@/app/admin/logs/hooks/useUserSearch', () => ({
|
||||
useUserSearch: () => ({
|
||||
users: [],
|
||||
filterByQuery: vi.fn(),
|
||||
findUserById: findUserByIdMock,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ActiveFilterChips', () => {
|
||||
beforeEach(() => {
|
||||
setFiltersMock.mockReset();
|
||||
removeFilterMock.mockReset();
|
||||
findUserByIdMock.mockReset();
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE };
|
||||
});
|
||||
|
||||
it('renders nothing when all filters are at default and no search', () => {
|
||||
const { container } = render(<ActiveFilterChips />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a status chip with the correct aria-label and label', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: status' });
|
||||
expect(chip).toHaveTextContent('Status: Failed');
|
||||
});
|
||||
|
||||
it('renders a job-type chip using JOB_TYPE_LABELS for the display label', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, type: 'search_indexers' };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: job type' });
|
||||
expect(chip).toHaveTextContent('Type: Search Indexers');
|
||||
});
|
||||
|
||||
it('renders an Errors only chip when hasError is true', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, hasError: true };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: errors only' });
|
||||
expect(chip).toHaveTextContent('Errors only');
|
||||
});
|
||||
|
||||
it('clicking a chip calls removeFilter with the correct key', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' };
|
||||
render(<ActiveFilterChips />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove filter: status' }));
|
||||
expect(removeFilterMock).toHaveBeenCalledWith('status');
|
||||
});
|
||||
|
||||
it('clicking the date chip clears both dateFrom and dateTo via setFilters', () => {
|
||||
mockFilters = {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
dateFrom: '2026-05-10T00:00:00.000Z',
|
||||
dateTo: '2026-05-12T00:00:00.000Z',
|
||||
};
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: date range' });
|
||||
fireEvent.click(chip);
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ dateFrom: null, dateTo: null });
|
||||
});
|
||||
|
||||
it('renders a search chip when search is non-empty', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, search: 'timeout' };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: search' });
|
||||
expect(chip).toHaveTextContent('Search: "timeout"');
|
||||
fireEvent.click(chip);
|
||||
expect(removeFilterMock).toHaveBeenCalledWith('search');
|
||||
});
|
||||
|
||||
it('user chip uses resolved plexUsername when available, falls back to id', () => {
|
||||
findUserByIdMock.mockReturnValue({ id: 'user-1', plexUsername: 'alice', role: 'user' });
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, userId: 'user-1' };
|
||||
const { unmount } = render(<ActiveFilterChips />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove filter: user' })
|
||||
).toHaveTextContent('User: alice');
|
||||
unmount();
|
||||
|
||||
findUserByIdMock.mockReturnValue(undefined);
|
||||
render(<ActiveFilterChips />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove filter: user' })
|
||||
).toHaveTextContent('User: user-1');
|
||||
});
|
||||
|
||||
it('audiobook chip shows the query string', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, audiobookQuery: 'Dune' };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: audiobook' });
|
||||
expect(chip).toHaveTextContent('Book: "Dune"');
|
||||
});
|
||||
|
||||
it('renders all chips together when multiple filters are active', () => {
|
||||
mockFilters = {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
status: 'failed',
|
||||
type: 'search_indexers',
|
||||
hasError: true,
|
||||
search: 'oops',
|
||||
audiobookQuery: 'Dune',
|
||||
};
|
||||
render(<ActiveFilterChips />);
|
||||
const group = screen.getByRole('group', { name: 'Active filters' });
|
||||
// Five chips for five active values.
|
||||
expect(group.querySelectorAll('button')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Component: Admin Logs — LogsFilters Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import LogsFilters from '@/app/admin/logs/components/LogsFilters';
|
||||
import { DEFAULT_FILTER_STATE, type LogsFilterState } from '@/app/admin/logs/types';
|
||||
|
||||
// ---- Mock hooks (the seam between components and page-level state) -------
|
||||
|
||||
const setFiltersMock = vi.fn();
|
||||
const clearAllMock = vi.fn();
|
||||
const removeFilterMock = vi.fn();
|
||||
const setSearchInputMock = vi.fn();
|
||||
let mockFilters: LogsFilterState = { ...DEFAULT_FILTER_STATE };
|
||||
|
||||
vi.mock('@/app/admin/logs/hooks/useLogsUrlState', () => ({
|
||||
useLogsUrlState: () => ({
|
||||
filters: mockFilters,
|
||||
setFilters: setFiltersMock,
|
||||
setSearchInput: setSearchInputMock,
|
||||
searchInput: mockFilters.search,
|
||||
clearAll: clearAllMock,
|
||||
removeFilter: removeFilterMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const registerMock = vi.fn();
|
||||
const unregisterMock = vi.fn();
|
||||
const useRegisterPauseReasonMock = vi.fn();
|
||||
|
||||
vi.mock('@/app/admin/logs/hooks/useAutoRefreshControl', () => ({
|
||||
useAutoRefreshControl: () => ({
|
||||
register: registerMock,
|
||||
unregister: unregisterMock,
|
||||
isPaused: false,
|
||||
isRunning: true,
|
||||
pauseReasons: [],
|
||||
enabled: true,
|
||||
setEnabled: vi.fn(),
|
||||
effectiveInterval: 10000,
|
||||
manualRefresh: vi.fn(),
|
||||
setMutate: vi.fn(),
|
||||
setLastUpdatedAt: vi.fn(),
|
||||
lastUpdatedAt: 0,
|
||||
}),
|
||||
useRegisterPauseReason: (reason: string, active: boolean) => {
|
||||
useRegisterPauseReasonMock(reason, active);
|
||||
React.useEffect(() => {
|
||||
if (active) registerMock(reason);
|
||||
else unregisterMock(reason);
|
||||
return () => unregisterMock(reason);
|
||||
}, [reason, active]);
|
||||
},
|
||||
}));
|
||||
|
||||
const filterByQueryMock = vi.fn();
|
||||
const findUserByIdMock = vi.fn();
|
||||
|
||||
vi.mock('@/app/admin/logs/hooks/useUserSearch', () => ({
|
||||
useUserSearch: () => ({
|
||||
users: [
|
||||
{ id: 'user-1', plexUsername: 'alice', role: 'user' },
|
||||
{ id: 'user-2', plexUsername: 'bob', role: 'admin' },
|
||||
],
|
||||
filterByQuery: filterByQueryMock,
|
||||
findUserById: findUserByIdMock,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---- Tests ---------------------------------------------------------------
|
||||
|
||||
describe('LogsFilters', () => {
|
||||
beforeEach(() => {
|
||||
setFiltersMock.mockReset();
|
||||
clearAllMock.mockReset();
|
||||
removeFilterMock.mockReset();
|
||||
registerMock.mockReset();
|
||||
unregisterMock.mockReset();
|
||||
useRegisterPauseReasonMock.mockReset();
|
||||
filterByQueryMock.mockReset();
|
||||
findUserByIdMock.mockReset();
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE };
|
||||
filterByQueryMock.mockReturnValue([
|
||||
{ id: 'user-1', plexUsername: 'alice', role: 'user' },
|
||||
{ id: 'user-2', plexUsername: 'bob', role: 'admin' },
|
||||
]);
|
||||
findUserByIdMock.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('renders the Status dropdown with all canonical options', () => {
|
||||
render(<LogsFilters />);
|
||||
const select = screen.getByLabelText('Status') as HTMLSelectElement;
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
expect(values).toEqual(['all', 'pending', 'active', 'completed', 'failed', 'delayed', 'stuck']);
|
||||
});
|
||||
|
||||
it('renders the Job Type dropdown with All Types + JOB_TYPE_LABELS in insertion order', () => {
|
||||
render(<LogsFilters />);
|
||||
const select = screen.getByLabelText('Job Type') as HTMLSelectElement;
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
// First option is 'all', followed by every JOB_TYPE_LABELS key.
|
||||
expect(values[0]).toBe('all');
|
||||
expect(values.slice(1, 5)).toEqual([
|
||||
'search_indexers',
|
||||
'download_torrent',
|
||||
'monitor_download',
|
||||
'organize_files',
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls setFilters({ status }) when the Status dropdown changes', () => {
|
||||
render(<LogsFilters />);
|
||||
const select = screen.getByLabelText('Status') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'failed' } });
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ status: 'failed' });
|
||||
});
|
||||
|
||||
it('clicking a preset date option calls setFilters with computed dateFrom and dateTo null', () => {
|
||||
render(<LogsFilters />);
|
||||
const dateSelect = screen.getByLabelText('Date Range') as HTMLSelectElement;
|
||||
fireEvent.change(dateSelect, { target: { value: 'last_7d' } });
|
||||
expect(setFiltersMock).toHaveBeenCalledTimes(1);
|
||||
const [call] = setFiltersMock.mock.calls;
|
||||
const payload = call[0] as Partial<LogsFilterState>;
|
||||
expect(payload.dateTo).toBeNull();
|
||||
expect(payload.dateFrom).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
const fromMs = new Date(payload.dateFrom as string).getTime();
|
||||
// 7 days ago, ±60s tolerance (test execution time).
|
||||
const expected = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
expect(Math.abs(fromMs - expected)).toBeLessThan(60_000);
|
||||
});
|
||||
|
||||
it('selecting Custom reveals datetime-local inputs', () => {
|
||||
render(<LogsFilters />);
|
||||
const dateSelect = screen.getByLabelText('Date Range') as HTMLSelectElement;
|
||||
fireEvent.change(dateSelect, { target: { value: 'custom' } });
|
||||
expect(screen.getByLabelText('Date from')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Date to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('typing in the Audiobook input calls setFilters with audiobookQuery', () => {
|
||||
render(<LogsFilters />);
|
||||
const input = screen.getByLabelText('Audiobook') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'Dune' } });
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ audiobookQuery: 'Dune' });
|
||||
});
|
||||
|
||||
it('user typeahead selection calls setFilters with the user id', () => {
|
||||
render(<LogsFilters />);
|
||||
const input = screen.getByLabelText('User') as HTMLInputElement;
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: 'al' } });
|
||||
// The popover renders the filtered options; click "alice" via mouseDown
|
||||
// (the component uses onMouseDown to avoid the blur race).
|
||||
const option = screen.getByRole('option', { name: /alice/ });
|
||||
fireEvent.mouseDown(option);
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('user typeahead clear button calls setFilters with userId null', () => {
|
||||
findUserByIdMock.mockReturnValue({ id: 'user-1', plexUsername: 'alice', role: 'user' });
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, userId: 'user-1' };
|
||||
render(<LogsFilters />);
|
||||
const clear = screen.getByRole('button', { name: 'Clear user filter' });
|
||||
fireEvent.click(clear);
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ userId: null });
|
||||
});
|
||||
|
||||
it('hides "Clear all filters" when no filters or search are active', () => {
|
||||
render(<LogsFilters />);
|
||||
expect(screen.queryByText('Clear all filters')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Clear all filters" when at least one filter is active and clears on click', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' };
|
||||
render(<LogsFilters />);
|
||||
const button = screen.getByText('Clear all filters');
|
||||
expect(button).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
expect(clearAllMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('registers a pause reason when the Status select is focused and unregisters on blur', () => {
|
||||
render(<LogsFilters />);
|
||||
const select = screen.getByLabelText('Status') as HTMLSelectElement;
|
||||
fireEvent.focus(select);
|
||||
expect(registerMock).toHaveBeenCalledWith('logs-status-dropdown');
|
||||
fireEvent.blur(select);
|
||||
expect(unregisterMock).toHaveBeenCalledWith('logs-status-dropdown');
|
||||
});
|
||||
|
||||
it('custom datetime-local input emits UTC ISO via setFilters', () => {
|
||||
render(<LogsFilters />);
|
||||
fireEvent.change(screen.getByLabelText('Date Range'), { target: { value: 'custom' } });
|
||||
const fromInput = screen.getByLabelText('Date from') as HTMLInputElement;
|
||||
fireEvent.change(fromInput, { target: { value: '2026-01-15T10:30' } });
|
||||
expect(setFiltersMock).toHaveBeenCalled();
|
||||
const lastCall = setFiltersMock.mock.calls.at(-1)?.[0] as Partial<LogsFilterState>;
|
||||
expect(lastCall.dateFrom).toMatch(/Z$/);
|
||||
// The submitted ISO must parse to the same wall-clock time the user typed,
|
||||
// interpreted as local. Round-trip check:
|
||||
const parsed = new Date(lastCall.dateFrom as string);
|
||||
const localRoundTrip = new Date(2026, 0, 15, 10, 30);
|
||||
expect(parsed.getTime()).toBe(localRoundTrip.getTime());
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,23 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AdminLogsPage from '@/app/admin/logs/page';
|
||||
import {
|
||||
buildLogsApiKey,
|
||||
DEFAULT_FILTER_STATE,
|
||||
LogsData,
|
||||
LogsFilterState,
|
||||
} from '@/app/admin/logs/types';
|
||||
|
||||
// ===========================================================================
|
||||
// Mocks
|
||||
// ===========================================================================
|
||||
|
||||
const useSWRMock = vi.hoisted(() => vi.fn());
|
||||
const routerReplaceMock = vi.hoisted(() => vi.fn());
|
||||
const searchParamsState = vi.hoisted(() => ({ value: new URLSearchParams() }));
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
default: (...args: any[]) => useSWRMock(...args),
|
||||
@@ -20,108 +32,562 @@ vi.mock('@/lib/utils/api', () => ({
|
||||
authenticatedFetcher: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AdminLogsPage', () => {
|
||||
beforeEach(() => {
|
||||
useSWRMock.mockReset();
|
||||
});
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ replace: routerReplaceMock, push: vi.fn() }),
|
||||
usePathname: () => '/admin/logs',
|
||||
useSearchParams: () => searchParamsState.value,
|
||||
}));
|
||||
|
||||
it('renders logs and toggles detail rows', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: {
|
||||
logs: [
|
||||
{
|
||||
id: 'log-1',
|
||||
bullJobId: 'bull-1',
|
||||
type: 'search_indexers',
|
||||
status: 'failed',
|
||||
priority: 1,
|
||||
attempts: 2,
|
||||
maxAttempts: 3,
|
||||
errorMessage: 'Search failed',
|
||||
startedAt: '2024-01-01T00:00:00Z',
|
||||
completedAt: '2024-01-01T00:02:00Z',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:02:00Z',
|
||||
result: { retries: 2 },
|
||||
events: [
|
||||
{
|
||||
id: 'event-1',
|
||||
level: 'error',
|
||||
context: 'SearchJob',
|
||||
message: 'Indexer timeout',
|
||||
metadata: { indexer: 'Example' },
|
||||
createdAt: '2024-01-01T00:01:00Z',
|
||||
},
|
||||
],
|
||||
request: {
|
||||
id: 'req-1',
|
||||
audiobook: { title: 'Search Book', author: 'Author' },
|
||||
user: { plexUsername: 'User' },
|
||||
},
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 50, total: 1, totalPages: 1 },
|
||||
// useUserSearch fires its own SWR call for /api/admin/users; we branch the
|
||||
// SWR mock by URL so both the logs key and the users key get sensible defaults.
|
||||
const mockMutate = vi.fn();
|
||||
function defaultSwrImpl(logsResponse: { data?: LogsData; error?: Error } = {}) {
|
||||
return (key: string) => {
|
||||
if (typeof key === 'string' && key.startsWith('/api/admin/users')) {
|
||||
return { data: { users: [] }, error: undefined, mutate: vi.fn(), isLoading: false };
|
||||
}
|
||||
return {
|
||||
data: logsResponse.data,
|
||||
error: logsResponse.error,
|
||||
mutate: mockMutate,
|
||||
isLoading: false,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Fixtures
|
||||
// ===========================================================================
|
||||
|
||||
function makeLog(overrides: Partial<any> = {}): any {
|
||||
return {
|
||||
id: 'log-1',
|
||||
bullJobId: 'bull-1',
|
||||
type: 'search_indexers',
|
||||
status: 'failed',
|
||||
priority: 1,
|
||||
attempts: 2,
|
||||
maxAttempts: 3,
|
||||
errorMessage: 'Search failed',
|
||||
startedAt: '2024-01-01T00:00:00Z',
|
||||
completedAt: '2024-01-01T00:02:00Z',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:02:00Z',
|
||||
result: { retries: 2 },
|
||||
events: [
|
||||
{
|
||||
id: 'event-1',
|
||||
level: 'error',
|
||||
context: 'SearchJob',
|
||||
message: 'Indexer timeout',
|
||||
metadata: { indexer: 'Example' },
|
||||
createdAt: '2024-01-01T00:01:00Z',
|
||||
},
|
||||
error: undefined,
|
||||
}));
|
||||
],
|
||||
request: {
|
||||
id: 'req-1',
|
||||
audiobook: { title: 'Search Book', author: 'Author' },
|
||||
user: { plexUsername: 'User' },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeData(logs: any[] = [makeLog()], pagination: Partial<any> = {}): LogsData {
|
||||
return {
|
||||
logs,
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: logs.length,
|
||||
totalPages: Math.max(1, Math.ceil(logs.length / 50)),
|
||||
...pagination,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Setup / teardown
|
||||
// ===========================================================================
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
useSWRMock.mockReset();
|
||||
routerReplaceMock.mockReset();
|
||||
mockMutate.mockReset();
|
||||
searchParamsState.value = new URLSearchParams();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Page-level tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('AdminLogsPage', () => {
|
||||
it('renders the page header and a desktop row from data', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText('System Logs')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Search Book')[0]).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Show Details' })[0]);
|
||||
expect(screen.getAllByText('Event Log')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Job Result')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Error')[0]).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Hide Details' })[0]);
|
||||
expect(screen.queryByText('Event Log')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the swr key when filters change', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } },
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
it('shows skeleton on initial load (no data, no error)', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: undefined, error: undefined }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const statusSelect = screen
|
||||
.getByText('Status', { selector: 'label' })
|
||||
.parentElement?.querySelector('select');
|
||||
expect(statusSelect).not.toBeNull();
|
||||
fireEvent.change(statusSelect as HTMLSelectElement, { target: { value: 'completed' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
'/api/admin/logs?page=1&limit=50&status=completed&type=all',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
expect(await screen.findByTestId('log-skeleton-mobile')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('log-skeleton-desktop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state when logs fail to load', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: undefined,
|
||||
error: new Error('Log failure'),
|
||||
}));
|
||||
|
||||
useSWRMock.mockImplementation(
|
||||
defaultSwrImpl({ data: undefined, error: new Error('Log failure') })
|
||||
);
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText('Error Loading Logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log failure')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no logs are returned', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } },
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
it('uses buildLogsApiKey for the SWR key (with hydrate-time 7d default applied)', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect((await screen.findAllByText('No logs found'))[0]).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const key = calls[0][0];
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
// Defaults: page=1, limit=50 always present.
|
||||
expect(params.get('page')).toBe('1');
|
||||
expect(params.get('limit')).toBe('50');
|
||||
// Zach Resolution #1: hydrate-time Last-7-days default → dateFrom set,
|
||||
// dateTo unset (sliding window to "now").
|
||||
const dateFrom = params.get('dateFrom');
|
||||
expect(dateFrom).not.toBeNull();
|
||||
expect(params.get('dateTo')).toBeNull();
|
||||
// Confirm the dateFrom is roughly 7 days ago (allow generous tolerance).
|
||||
const fromMs = new Date(dateFrom as string).getTime();
|
||||
const expected = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
expect(Math.abs(fromMs - expected)).toBeLessThan(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the SWR key when Errors-only pill is activated', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const pill = await screen.findByRole('button', { name: /errors only/i });
|
||||
fireEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerReplaceMock).toHaveBeenCalled();
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
expect(lastCall[0]).toContain('hasError=1');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders fresh empty state when no rows, no filters, no search', async () => {
|
||||
// Note: hydrate-time 7d default IS applied here, but the page's
|
||||
// empty-state branch treats the implicit default as "not user-applied",
|
||||
// so the "fresh" copy still wins.
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/No background jobs have run yet/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('skips hydrate-time 7d default when URL already has dateFrom', async () => {
|
||||
searchParamsState.value = new URLSearchParams('dateFrom=2024-01-01T00:00:00.000Z');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
const key = calls[calls.length - 1][0];
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
// URL-provided dateFrom wins; hydrate default does NOT replace it.
|
||||
expect(params.get('dateFrom')).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
it('retires hydrate default after user retires dates via setFilters', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
// First confirm hydrate is active.
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
const key = calls[calls.length - 1][0];
|
||||
expect(new URLSearchParams(key.split('?')[1]).get('dateFrom')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Click Errors-only — this writes URL. The hydrate dates ride along in
|
||||
// the merge, so URL now carries an explicit dateFrom.
|
||||
const pill = await screen.findByRole('button', { name: /errors only/i });
|
||||
fireEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerReplaceMock).toHaveBeenCalled();
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
const params = new URLSearchParams((lastCall[0] as string).split('?')[1] ?? '');
|
||||
expect(params.get('hasError')).toBe('1');
|
||||
expect(params.get('dateFrom')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders search-no-match empty state with Clear search button', async () => {
|
||||
searchParamsState.value = new URLSearchParams('search=foo');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText(/No matches for/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear search and show all logs/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders filters-too-tight empty state with Clear filters button', async () => {
|
||||
searchParamsState.value = new URLSearchParams('status=failed');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/No logs match your current filters/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hydrates filter state from URL on mount', async () => {
|
||||
searchParamsState.value = new URLSearchParams('status=failed&hasError=1&page=2');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const key = calls[calls.length - 1][0];
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
expect(params.get('status')).toBe('failed');
|
||||
expect(params.get('hasError')).toBe('1');
|
||||
expect(params.get('page')).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
it('silently drops invalid URL params', async () => {
|
||||
searchParamsState.value = new URLSearchParams(
|
||||
'status=garbage&type=not_a_type&limit=37&page=abc'
|
||||
);
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const key = calls[calls.length - 1][0];
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
// Invalid values silently dropped → defaults applied
|
||||
expect(params.get('status')).toBeNull();
|
||||
expect(params.get('type')).toBeNull();
|
||||
// page + limit fall back to defaults (1 / 50) which the SWR key always sets
|
||||
expect(params.get('page')).toBe('1');
|
||||
expect(params.get('limit')).toBe('50');
|
||||
});
|
||||
});
|
||||
|
||||
it('debounces search input — fast keystrokes produce ONE URL write', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const search = await screen.findByLabelText(/search logs/i);
|
||||
fireEvent.change(search, { target: { value: 'a' } });
|
||||
fireEvent.change(search, { target: { value: 'ab' } });
|
||||
fireEvent.change(search, { target: { value: 'abc' } });
|
||||
|
||||
// Wait past the 300ms debounce window — only ONE URL write should land,
|
||||
// with the final value.
|
||||
await waitFor(
|
||||
() => {
|
||||
const searchCalls = routerReplaceMock.mock.calls.filter((c: any[]) =>
|
||||
(c[0] as string).includes('search=')
|
||||
);
|
||||
expect(searchCalls.length).toBe(1);
|
||||
expect(searchCalls[0][0]).toContain('search=abc');
|
||||
},
|
||||
{ timeout: 1500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('shows search clear (×) when populated and clears search on click', async () => {
|
||||
searchParamsState.value = new URLSearchParams('search=foo');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
// The toolbar's × button has aria-label="Clear search" (exact match).
|
||||
const clearBtn = await screen.findByRole('button', { name: 'Clear search' });
|
||||
fireEvent.click(clearBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerReplaceMock).toHaveBeenCalled();
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
expect(lastCall[0]).not.toContain('search=');
|
||||
});
|
||||
});
|
||||
|
||||
it('Refresh-now button triggers SWR mutate', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const refresh = await screen.findByRole('button', { name: /refresh now/i });
|
||||
fireEvent.click(refresh);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Auto-refresh toggle persists state to sessionStorage', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const toggle = await screen.findByRole('switch', { name: /auto-refresh/i });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
expect(window.sessionStorage.getItem('admin-logs:auto-refresh-enabled')).toBe('0');
|
||||
});
|
||||
|
||||
it('Auto-refresh OFF makes effectiveInterval=0 in the SWR call', async () => {
|
||||
window.sessionStorage.setItem('admin-logs:auto-refresh-enabled', '0');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const logsCalls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
const lastCfg = logsCalls[logsCalls.length - 1][2];
|
||||
expect(lastCfg.refreshInterval).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('expanding a row pauses auto-refresh (refreshInterval=0)', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const discloseButtons = await screen.findAllByRole('button', { name: /show details/i });
|
||||
fireEvent.click(discloseButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const logsCalls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
const lastCfg = logsCalls[logsCalls.length - 1][2];
|
||||
expect(lastCfg.refreshInterval).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('Live indicator shows Paused when auto-refresh disabled', async () => {
|
||||
window.sessionStorage.setItem('admin-logs:auto-refresh-enabled', '0');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const indicator = screen.getByTestId('logs-live-indicator');
|
||||
expect(indicator.getAttribute('data-state')).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
it('pagination shows total result count and Page X of Y', async () => {
|
||||
useSWRMock.mockImplementation(
|
||||
defaultSwrImpl({ data: makeData([makeLog()], { total: 247, totalPages: 5 }) })
|
||||
);
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const summary = await screen.findByTestId('logs-pagination-summary');
|
||||
expect(summary.textContent).toContain('247');
|
||||
expect(summary.textContent).toMatch(/Page\s*1\s*of\s*5/);
|
||||
});
|
||||
|
||||
it('changing page-size triggers URL update with new limit and resets to page 1', async () => {
|
||||
searchParamsState.value = new URLSearchParams('page=3');
|
||||
useSWRMock.mockImplementation(
|
||||
defaultSwrImpl({ data: makeData([makeLog()], { page: 3, total: 200, totalPages: 4 }) })
|
||||
);
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const sizeSelect = await screen.findByLabelText(/page size/i);
|
||||
fireEvent.change(sizeSelect, { target: { value: '100' } });
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
expect(lastCall[0]).toContain('limit=100');
|
||||
expect(lastCall[0]).not.toContain('page=');
|
||||
});
|
||||
});
|
||||
|
||||
it('changing a filter resets pagination to page 1', async () => {
|
||||
searchParamsState.value = new URLSearchParams('page=4');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const pill = await screen.findByRole('button', { name: /errors only/i });
|
||||
fireEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
const params = new URLSearchParams((lastCall[0] as string).split('?')[1] ?? '');
|
||||
expect(params.get('hasError')).toBe('1');
|
||||
expect(params.get('page')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('disclosure button has rotating chevron and ARIA expanded state', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const discloseButtons = await screen.findAllByRole('button', { name: /show details/i });
|
||||
const button = discloseButtons[0];
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
const chevron = button.querySelector('svg');
|
||||
expect(chevron?.className.baseVal ?? chevron?.getAttribute('class') ?? '').not.toContain(
|
||||
'rotate-180'
|
||||
);
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||
const updatedChevron = button.querySelector('svg');
|
||||
const cls = updatedChevron?.className.baseVal ?? updatedChevron?.getAttribute('class') ?? '';
|
||||
expect(cls).toContain('rotate-180');
|
||||
});
|
||||
|
||||
it('detail panel shows Event Log / Job Result / Error sections when expanded', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const discloseButtons = await screen.findAllByRole('button', { name: /show details/i });
|
||||
fireEvent.click(discloseButtons[0]);
|
||||
|
||||
expect(screen.getAllByRole('button', { name: /event log/i }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('button', { name: /job result/i }).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /^error$/i }).length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('copy button on Bull Job ID calls clipboard and shows toast', async () => {
|
||||
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText: writeTextMock } });
|
||||
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
|
||||
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const discloseButtons = await screen.findAllByRole('button', { name: /show details/i });
|
||||
fireEvent.click(discloseButtons[0]);
|
||||
|
||||
const copyButtons = screen.getAllByRole('button', { name: /copy bull job id/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(copyButtons[0]);
|
||||
});
|
||||
|
||||
expect(writeTextMock).toHaveBeenCalledWith('bull-1');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Copied Bull Job ID/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides disclosure button when log has no details', async () => {
|
||||
const log = makeLog({
|
||||
events: [],
|
||||
errorMessage: null,
|
||||
bullJobId: null,
|
||||
result: null,
|
||||
});
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([log]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await screen.findAllByText('Search Book');
|
||||
expect(screen.queryByRole('button', { name: /show details/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('jump-to-page input on Enter dispatches a page change', async () => {
|
||||
useSWRMock.mockImplementation(
|
||||
defaultSwrImpl({ data: makeData([makeLog()], { total: 200, totalPages: 4 }) })
|
||||
);
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const jump = await screen.findByLabelText(/jump to page/i);
|
||||
fireEvent.change(jump, { target: { value: '3' } });
|
||||
fireEvent.keyDown(jump, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
expect(lastCall[0]).toContain('page=3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// buildLogsApiKey unit tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('buildLogsApiKey', () => {
|
||||
it('omits defaults so the key stays short', () => {
|
||||
const key = buildLogsApiKey(DEFAULT_FILTER_STATE);
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
expect(params.get('page')).toBe('1');
|
||||
expect(params.get('limit')).toBe('50');
|
||||
expect(params.get('status')).toBeNull();
|
||||
expect(params.get('type')).toBeNull();
|
||||
expect(params.get('search')).toBeNull();
|
||||
expect(params.get('hasError')).toBeNull();
|
||||
});
|
||||
|
||||
it('includes every active filter', () => {
|
||||
const state: LogsFilterState = {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
search: 'foo',
|
||||
status: 'failed',
|
||||
type: 'search_indexers',
|
||||
dateFrom: '2024-01-01T00:00:00Z',
|
||||
dateTo: '2024-01-02T00:00:00Z',
|
||||
hasError: true,
|
||||
userId: 'user-123',
|
||||
audiobookQuery: 'Mistborn',
|
||||
page: 2,
|
||||
limit: 100,
|
||||
};
|
||||
const params = new URLSearchParams(buildLogsApiKey(state).split('?')[1] ?? '');
|
||||
expect(params.get('search')).toBe('foo');
|
||||
expect(params.get('status')).toBe('failed');
|
||||
expect(params.get('type')).toBe('search_indexers');
|
||||
expect(params.get('dateFrom')).toBe('2024-01-01T00:00:00Z');
|
||||
expect(params.get('dateTo')).toBe('2024-01-02T00:00:00Z');
|
||||
expect(params.get('hasError')).toBe('1');
|
||||
expect(params.get('userId')).toBe('user-123');
|
||||
expect(params.get('audiobookQuery')).toBe('Mistborn');
|
||||
expect(params.get('page')).toBe('2');
|
||||
expect(params.get('limit')).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user