import { useEffect, useState, useCallback } from 'react' import { X, RefreshCw, Wrench, Film, Image as ImageIcon, AlertTriangle, Database, Loader2, Cpu, AlertCircle, CheckCircle2, Copy, Sparkles, } from 'lucide-react' import clsx from 'clsx' import { useQuery, useQueryClient } from '@tanstack/react-query' import { library, type MediaType, } from '../../services/api' import { toast } from '../ToastContainer' // React Query keys for the settings panels. Kept here (not in a shared // hook module) since they're internal to this dialog and used by the // runAction refresh step to invalidate after mutations. const SETTINGS_THUMB_STATS_KEY = ['settings', 'thumbnail-stats'] as const const SETTINGS_LIB_STATS_KEY = ['settings', 'library-stats'] as const const SETTINGS_WORKER_STATUS_KEY = ['settings', 'worker-status'] as const const SETTINGS_MISSING_STATS_KEY = ['settings', 'missing-stats'] as const // Shared with the DuplicatesView so a regroup invalidates the same cache // the grid renders from. Imported via the canonical hook key. import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery' interface SettingsDialogProps { isOpen: boolean onClose: () => void } /** * Catch-all "settings + admin" panel. Currently exposes the maintenance * endpoints exposed by /api/v1/library/maintenance/* — regenerate * thumbnails (with filters), run the data-integrity cleanup, and trigger * a full library re-scan. The thumbnail stats block is the entry point * users will look at to understand what's going on after a scan. * * Each action is gated by an in-flight flag so double-clicks don't * stack background jobs, and the stats block re-fetches whenever the * dialog opens or after any action completes. */ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { const queryClient = useQueryClient() const [showAllErrors, setShowAllErrors] = useState(false) // One key per action so each button has its own spinner without // blocking the others. const [busy, setBusy] = useState>({}) // All four panels fetch through React Query so cached data shows // instantly on reopen while a background refetch updates the numbers. // `enabled: isOpen` avoids firing requests while the dialog is closed, // but the cache entries survive between opens (default gcTime = 5m). const thumbStatsQuery = useQuery({ queryKey: SETTINGS_THUMB_STATS_KEY, queryFn: library.maintenance.thumbnailStats, enabled: isOpen, // Treat as stale immediately so reopening the dialog triggers a // background refetch on top of the cached view. staleTime: 0, }) const libStatsQuery = useQuery({ queryKey: SETTINGS_LIB_STATS_KEY, queryFn: library.stats, enabled: isOpen, staleTime: 0, }) // Worker status polls every 5s while the dialog is open — `refetchInterval` // replaces the old setInterval loop. Missing-stats is relatively cheap // but shares the same 5s rhythm to keep the orphan banner live. const workerStatusQuery = useQuery({ queryKey: SETTINGS_WORKER_STATUS_KEY, queryFn: library.maintenance.workerStatus, enabled: isOpen, refetchInterval: isOpen ? 5000 : false, staleTime: 0, }) const missingStatsQuery = useQuery({ queryKey: SETTINGS_MISSING_STATS_KEY, queryFn: library.maintenance.missingStats, enabled: isOpen, refetchInterval: isOpen ? 5000 : false, staleTime: 0, }) // Duplicates: shares the cache with DuplicatesView so a regroup // triggered from Settings updates the grid view immediately. const duplicatesQuery = useQuery({ queryKey: DUPLICATE_GROUPS_QUERY_KEY, queryFn: library.duplicates.groups, enabled: isOpen, staleTime: 0, }) const thumbStats = thumbStatsQuery.data const libStats = libStatsQuery.data const workerStatus = workerStatusQuery.data const missingStats = missingStatsQuery.data // "loading" in the UI sense = fetching AND no cached data yet. Background // refetches on top of cached data shouldn't flip the refresh spinners. const loadingStats = (thumbStatsQuery.isFetching && !thumbStatsQuery.data) || (libStatsQuery.isFetching && !libStatsQuery.data) const loadingWorkers = (workerStatusQuery.isFetching && !workerStatusQuery.data) || (missingStatsQuery.isFetching && !missingStatsQuery.data) const refreshStats = useCallback(() => { queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY }) queryClient.invalidateQueries({ queryKey: SETTINGS_LIB_STATS_KEY }) queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_KEY }) }, [queryClient]) const refreshWorkers = useCallback(() => { queryClient.invalidateQueries({ queryKey: SETTINGS_WORKER_STATUS_KEY }) queryClient.invalidateQueries({ queryKey: SETTINGS_MISSING_STATS_KEY }) }, [queryClient]) // Surface fetch errors once (React Query de-dupes retries but we still // want a single toast so the user knows something went wrong). useEffect(() => { if (!isOpen) return if (thumbStatsQuery.error || libStatsQuery.error) { console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error) toast.error('Could not load library stats') } }, [isOpen, thumbStatsQuery.error, libStatsQuery.error]) useEffect(() => { if (!isOpen) return if (workerStatusQuery.error || missingStatsQuery.error) { console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error) toast.error('Could not load worker status') } }, [isOpen, workerStatusQuery.error, missingStatsQuery.error]) // Esc closes. useEffect(() => { if (!isOpen) return const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [isOpen, onClose]) const runAction = useCallback( async ( key: string, fn: () => Promise, successTitle: string, describe?: (result: T) => string | undefined ) => { if (busy[key]) return setBusy((b) => ({ ...b, [key]: true })) try { const result = await fn() toast.success(successTitle, describe?.(result)) refreshStats() refreshWorkers() } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e) toast.error(`${successTitle} failed`, message) } finally { setBusy((b) => ({ ...b, [key]: false })) } }, [busy, refreshStats, refreshWorkers] ) const regenerate = useCallback( ( key: string, body: { media_types?: MediaType[] only_failed?: boolean only_pending?: boolean } ) => runAction( key, () => library.maintenance.regenerateThumbnails(body), 'Regeneration queued', (r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared` ), [runAction] ) if (!isOpen) return null return (
{/* Header */}

Settings

{/* ----------------------------------------------------- */} {/* Library overview */} {/* ----------------------------------------------------- */}
} title="Library" right={ } >
runAction( 'scan', () => library.scan(), 'Library scan started' ) } > Re-scan source folders
{/* ----------------------------------------------------- */} {/* Duplicate detection */} {/* ----------------------------------------------------- */}
} title="Duplicates" >

Duplicates are detected by perceptual hash (pHash), which catches visually-identical photos even when their bytes differ — re-encoded JPEGs, screenshots, resized exports. Backfill computes hashes for photos that existed before pHash was added; Re-detect re-runs clustering across the whole library.

runAction( 'backfill-phashes', () => library.maintenance.backfillPhashes(), 'pHash backfill queued' ) } > Backfill perceptual hashes runAction( 'regroup-duplicates', () => library.maintenance.regroupDuplicates(), 'Duplicate detection queued' ) } > Re-detect duplicates
{/* ----------------------------------------------------- */} {/* Thumbnail maintenance */} {/* ----------------------------------------------------- */}
} title="Thumbnails" >
0 ? 'warn' : 'muted'} />

Reset on-disk thumbnails and re-queue generation. Use after upgrading the worker or to fix the gray placeholders left behind by an earlier failure.

regenerate('regen-videos', { media_types: ['video'] }) } > Regenerate video thumbnails regenerate('regen-failed', { only_failed: true }) } disabled={!!thumbStats && thumbStats.failed === 0} > Retry failed {thumbStats ? ` (${thumbStats.failed})` : ''} regenerate('regen-pending', { only_pending: true }) } disabled={!!thumbStats && thumbStats.pending === 0} > Kick pending {thumbStats ? ` (${thumbStats.pending})` : ''} { if ( !confirm( 'Regenerate thumbnails for the entire library? ' + 'This will queue every photo and may take a while.' ) ) return regenerate('regen-all', {}) }} > Regenerate all
{/* ----------------------------------------------------- */} {/* Worker fleet diagnostics */} {/* ----------------------------------------------------- */}
} title="Workers" right={ } > {/* Top-line health */}
0 ? 'ok' : 'warn' : 'muted' } /> 0 ? 'warn' : 'muted' } />
{/* Inline error banners for the obvious failure modes */} {workerStatus?.broker_error && ( )} {workerStatus?.inspect_error && ( )} {workerStatus && workerStatus.broker_ok && workerStatus.worker_count === 0 && ( )} {/* Queue depth */} {workerStatus && (
Queue depth
{Object.entries(workerStatus.queues).map(([name, depth]) => (
{name} 0 ? 'text-text' : 'text-text-muted' )} > {depth}
))}
)} {/* Orphaned rows (files gone from disk) */} {missingStats && ((missingStats.would_delete ?? 0) > 0 || (missingStats.would_delete_folders ?? 0) > 0) && (
Orphaned rows
{missingStats.would_delete ?? 0} photos and{' '} {missingStats.would_delete_folders ?? 0} folders point at paths that no longer exist on disk under a mounted source root. Usually means PHOTO_DIRS was repointed at a different library. {missingStats.skipped_unmounted > 0 && ( <> {' '} {missingStats.skipped_unmounted} more rows are under unmounted roots and will not be touched. )}
{ const photos = missingStats.would_delete ?? 0 const folders = missingStats.would_delete_folders ?? 0 if ( !confirm( `Delete ${photos} photo rows and ${folders} folder rows whose paths are missing? ` + 'This cannot be undone.' ) ) return runAction( 'prune-missing', () => library.maintenance.pruneMissing(), 'Orphans pruned', (r) => `${r.deleted ?? 0} photos + ${r.deleted_folders ?? 0} folders deleted` ) }} > Prune
)} {/* Per-worker breakdown */} {workerStatus && workerStatus.workers.length > 0 && (
Worker fleet
{workerStatus.workers.map((w) => (
{w.status === 'online' ? ( ) : ( )} {w.name}
{w.active}/{w.concurrency ?? '?'} active
reserved: {w.reserved} scheduled: {w.scheduled} {w.queues.length > 0 && ( queues: {w.queues.join(', ')} )}
{w.active_tasks.length > 0 && (
{w.active_tasks.map((t) => (
{t.name}{' '} {Array.isArray(t.args) ? t.args.map((a) => String(a)).join(', ') : ''}
))}
)}
))}
)} {/* Recent task failures */} {workerStatus && workerStatus.failures.recent.length > 0 && (
Recent failures ({workerStatus.failures.total})
{workerStatus.failures.recent.length > 5 && ( )}
{(showAllErrors ? workerStatus.failures.recent : workerStatus.failures.recent.slice(0, 5) ).map((f) => (
{f.filename} {f.media_type}
{f.error || '(no error message)'}
))}
)} {/* Recent scan errors (Redis list) */} {workerStatus && workerStatus.scan_errors.length > 0 && (
Scan errors
{workerStatus.scan_errors.map((e, i) => (
{e}
))}
)} {!workerStatus && (
Loading worker status…
)}
{/* ----------------------------------------------------- */} {/* Data integrity */} {/* ----------------------------------------------------- */}
} title="Maintenance" >

Re-runs the source-roots / folders / photos integrity cleanup that normally only runs on backend startup. Safe to run any time.

runAction( 'cleanup', () => library.maintenance.cleanup(), 'Data integrity cleanup complete' ) } > Run data integrity cleanup
) } // --------------------------------------------------------------------------- // Local presentational helpers — kept private to this file. // --------------------------------------------------------------------------- function Section({ icon, title, right, children, }: { icon: React.ReactNode title: string right?: React.ReactNode children: React.ReactNode }) { return (

{icon} {title}

{right}
{children}
) } function Stat({ label, value, tone = 'muted', }: { label: string value: number | string | undefined tone?: 'ok' | 'warn' | 'muted' }) { const toneClass = tone === 'ok' ? 'text-pick' : tone === 'warn' ? 'text-reject' : 'text-text' return (
{label}
{value ?? '—'}
) } function ErrorBanner({ title, detail }: { title: string; detail: string }) { return (
{title}
{detail}
) } function ActionButton({ loading, disabled, destructive, onClick, children, }: { loading?: boolean disabled?: boolean destructive?: boolean onClick: () => void children: React.ReactNode }) { return ( ) }