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 && (
-
-
-
- {isScanning ? 'Scanning...' : 'Scan all folders'}
-
-
- )}
+ {/* 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. */}
+
+
+
+ Settings
+
+
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({
)}
-
-
-
)