diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 50141f5..a951062 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -285,16 +285,31 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)): import redis as _redis # ----- Celery inspect (workers + active tasks) ------------------------- + # Each inspect.* call is a separate broadcast-and-wait with its own + # timeout, so running them serially multiplies the wait. Fan them out + # to threads and gather, collapsing 6 × timeout into ~1 × timeout. + # Timeout dropped to 0.5s — a responsive worker answers within a few + # ms; anything past that is effectively "not responding" for the + # purposes of a settings dashboard. + import asyncio workers: list[dict] = [] inspect_error: Optional[str] = None try: - inspect = celery_app.control.inspect(timeout=1.0) - ping = inspect.ping() or {} - active = inspect.active() or {} - reserved = inspect.reserved() or {} - scheduled = inspect.scheduled() or {} - stats = inspect.stats() or {} - active_queues = inspect.active_queues() or {} + inspect = celery_app.control.inspect(timeout=0.5) + ping, active, reserved, scheduled, stats, active_queues = await asyncio.gather( + asyncio.to_thread(inspect.ping), + asyncio.to_thread(inspect.active), + asyncio.to_thread(inspect.reserved), + asyncio.to_thread(inspect.scheduled), + asyncio.to_thread(inspect.stats), + asyncio.to_thread(inspect.active_queues), + ) + ping = ping or {} + active = active or {} + reserved = reserved or {} + scheduled = scheduled or {} + stats = stats or {} + active_queues = active_queues or {} worker_names = set(ping) | set(active) | set(stats) for name in sorted(worker_names): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ef9f55..dd979fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,7 +44,6 @@ function App() { return (
setSettingsOpen(true)} leftSidebarOpen={leftSidebarOpen} rightSidebarOpen={showRightSidebar} onExpandLeft={() => setLeftSidebarOpen(true)} @@ -58,7 +57,10 @@ function App() { leftSidebarOpen ? 'w-64' : 'w-0' } overflow-hidden border-r border-border bg-surface`} > - setLeftSidebarOpen(false)} /> + setLeftSidebarOpen(false)} + onOpenSettings={() => setSettingsOpen(true)} + />
{/* Main column — filter bar, discard bar, timeline. Lives to the diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx index fb8d0de..9256a66 100644 --- a/frontend/src/components/dialogs/SettingsDialog.tsx +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -13,16 +13,21 @@ import { CheckCircle2, } from 'lucide-react' import clsx from 'clsx' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { library, - type ThumbnailStats, - type LibraryStats, type MediaType, - type WorkerStatus, - type MissingStats, } 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 + interface SettingsDialogProps { isOpen: boolean onClose: () => void @@ -40,70 +45,96 @@ interface SettingsDialogProps { * dialog opens or after any action completes. */ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { - const [thumbStats, setThumbStats] = useState(null) - const [libStats, setLibStats] = useState(null) - const [workerStatus, setWorkerStatus] = useState(null) - const [missingStats, setMissingStats] = useState(null) - const [loadingStats, setLoadingStats] = useState(false) - const [loadingWorkers, setLoadingWorkers] = useState(false) + 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>({}) - const refreshStats = useCallback(async () => { - setLoadingStats(true) - try { - const [thumbs, lib] = await Promise.all([ - library.maintenance.thumbnailStats(), - library.stats(), - ]) - setThumbStats(thumbs) - setLibStats(lib) - } catch (e) { - console.error('Failed to load settings stats', e) - toast.error('Could not load library stats') - } finally { - setLoadingStats(false) - } - }, []) + // 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, + }) - const refreshWorkers = useCallback(async () => { - setLoadingWorkers(true) - try { - const [ws, ms] = await Promise.all([ - library.maintenance.workerStatus(), - library.maintenance.missingStats(), - ]) - setWorkerStatus(ws) - setMissingStats(ms) - } catch (e) { - console.error('Failed to load worker status', e) - toast.error('Could not load worker status') - } finally { - setLoadingWorkers(false) - } - }, []) + 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) - // Esc closes; load stats when opened. Workers section auto-polls - // every 5s while the dialog is open so the user sees live worker - // activity without manually hammering the refresh button. + const refreshStats = useCallback(() => { + queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY }) + queryClient.invalidateQueries({ queryKey: SETTINGS_LIB_STATS_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 - refreshStats() - refreshWorkers() const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } window.addEventListener('keydown', handler) - const poll = window.setInterval(() => { - refreshWorkers() - }, 5000) - return () => { - window.removeEventListener('keydown', handler) - window.clearInterval(poll) - } - }, [isOpen, onClose, refreshStats, refreshWorkers]) + return () => window.removeEventListener('keydown', handler) + }, [isOpen, onClose]) const runAction = useCallback( async ( @@ -117,7 +148,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { try { const result = await fn() toast.success(successTitle, describe?.(result)) - await Promise.all([refreshStats(), refreshWorkers()]) + refreshStats() + refreshWorkers() } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e) toast.error(`${successTitle} failed`, message) diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 5caa97e..a2b99b0 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -8,16 +8,16 @@ import { Star, Trash2, HardDrive, - RefreshCw, Copy, Tag as TagIcon, Layers2, MoreHorizontal, Pencil, PanelLeftClose, + Settings, } from 'lucide-react' import clsx from 'clsx' -import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api' +import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api' import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from '../ToastContainer' import { useFilterStore } from '../../store/filterStore' @@ -44,11 +44,11 @@ interface TreeItem { interface LeftSidebarProps { onCollapse: () => void + onOpenSettings: () => void } -export function LeftSidebar({ onCollapse }: LeftSidebarProps) { +export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { const [expandedItems, setExpandedItems] = useState>(new Set(['library', 'folders', 'heaps'])) - const [isScanning, setIsScanning] = useState(false) // Inline rename state for source-root rows. Stores the id being edited // and the draft name. Double-click a folder row to start. const [renamingId, setRenamingId] = useState(null) @@ -309,30 +309,6 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'), }) - // Mutation for scanning all folders - const scanLibraryMutation = useMutation({ - mutationFn: library.scan, - onMutate: () => { - setIsScanning(true) - toast.info('Scan Started', 'Scanning all folders for new photos...') - }, - onSuccess: () => { - toast.success('Scan Complete', 'All folders have been scanned') - }, - onError: (error: any) => { - toast.error('Scan Failed', error.message || 'Failed to scan folders') - }, - onSettled: () => { - setIsScanning(false) - // Refetch photos after scan - queryClient.invalidateQueries({ queryKey: ['photos'] }) - }, - }) - - const handleScanAll = () => { - scanLibraryMutation.mutate() - } - const toggleExpanded = (id: string) => { const newExpanded = new Set(expandedItems) if (newExpanded.has(id)) { @@ -698,19 +674,18 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) { - {/* Bottom Actions */} - {folderTree.length > 0 && ( -
- -
- )} + {/* Settings entry point — pinned to the bottom of the panel so it + * sits out of the way of the library tree but is always reachable. */} +
+ +
void leftSidebarOpen: boolean rightSidebarOpen: boolean onExpandLeft: () => void @@ -21,7 +20,6 @@ interface TopBarProps { * collapse twin lives in the panel's own header. */ export function TopBar({ - onOpenSettings, leftSidebarOpen, rightSidebarOpen, onExpandLeft, @@ -54,13 +52,6 @@ export function TopBar({ )} - )