perf: speed up settings dialog + relocate settings entry point

- Parallelize the six celery inspect.*() calls in /library/maintenance/
  worker-status via asyncio.gather + to_thread, and drop per-call
  timeout from 1.0s to 0.5s. Endpoint goes from ~6.1s to ~0.54s — it
  was the sole bottleneck on opening the Settings dialog.
- SettingsDialog now fetches through React Query with enabled:isOpen,
  so reopening shows cached data instantly while a background refetch
  updates. Worker polling moved to refetchInterval. Loading spinners
  only show when there's no cached data yet, so background refetches
  don't keep them spinning.
- Move the Settings entry point from the TopBar to a pinned row at the
  bottom of the LeftSidebar so it sits alongside the other library
  controls. TopBar no longer takes onOpenSettings.
- Remove the "Scan all folders" bottom action from LeftSidebar — the
  same control already lives in Settings → Library → Re-scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:31:52 +02:00
parent d6c667ae78
commit e51b93d59e
5 changed files with 131 additions and 116 deletions

View File

@@ -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<ThumbnailStats | null>(null)
const [libStats, setLibStats] = useState<LibraryStats | null>(null)
const [workerStatus, setWorkerStatus] = useState<WorkerStatus | null>(null)
const [missingStats, setMissingStats] = useState<MissingStats | null>(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<Record<string, boolean>>({})
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 <T,>(
@@ -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)