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:
@@ -285,16 +285,31 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
|||||||
import redis as _redis
|
import redis as _redis
|
||||||
|
|
||||||
# ----- Celery inspect (workers + active tasks) -------------------------
|
# ----- 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] = []
|
workers: list[dict] = []
|
||||||
inspect_error: Optional[str] = None
|
inspect_error: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
inspect = celery_app.control.inspect(timeout=1.0)
|
inspect = celery_app.control.inspect(timeout=0.5)
|
||||||
ping = inspect.ping() or {}
|
ping, active, reserved, scheduled, stats, active_queues = await asyncio.gather(
|
||||||
active = inspect.active() or {}
|
asyncio.to_thread(inspect.ping),
|
||||||
reserved = inspect.reserved() or {}
|
asyncio.to_thread(inspect.active),
|
||||||
scheduled = inspect.scheduled() or {}
|
asyncio.to_thread(inspect.reserved),
|
||||||
stats = inspect.stats() or {}
|
asyncio.to_thread(inspect.scheduled),
|
||||||
active_queues = inspect.active_queues() or {}
|
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)
|
worker_names = set(ping) | set(active) | set(stats)
|
||||||
for name in sorted(worker_names):
|
for name in sorted(worker_names):
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-bg text-text">
|
<div className="flex flex-col h-screen bg-bg text-text">
|
||||||
<TopBar
|
<TopBar
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
|
||||||
leftSidebarOpen={leftSidebarOpen}
|
leftSidebarOpen={leftSidebarOpen}
|
||||||
rightSidebarOpen={showRightSidebar}
|
rightSidebarOpen={showRightSidebar}
|
||||||
onExpandLeft={() => setLeftSidebarOpen(true)}
|
onExpandLeft={() => setLeftSidebarOpen(true)}
|
||||||
@@ -58,7 +57,10 @@ function App() {
|
|||||||
leftSidebarOpen ? 'w-64' : 'w-0'
|
leftSidebarOpen ? 'w-64' : 'w-0'
|
||||||
} overflow-hidden border-r border-border bg-surface`}
|
} overflow-hidden border-r border-border bg-surface`}
|
||||||
>
|
>
|
||||||
<LeftSidebar onCollapse={() => setLeftSidebarOpen(false)} />
|
<LeftSidebar
|
||||||
|
onCollapse={() => setLeftSidebarOpen(false)}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main column — filter bar, discard bar, timeline. Lives to the
|
{/* Main column — filter bar, discard bar, timeline. Lives to the
|
||||||
|
|||||||
@@ -13,16 +13,21 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
library,
|
library,
|
||||||
type ThumbnailStats,
|
|
||||||
type LibraryStats,
|
|
||||||
type MediaType,
|
type MediaType,
|
||||||
type WorkerStatus,
|
|
||||||
type MissingStats,
|
|
||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
import { toast } from '../ToastContainer'
|
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 {
|
interface SettingsDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -40,70 +45,96 @@ interface SettingsDialogProps {
|
|||||||
* dialog opens or after any action completes.
|
* dialog opens or after any action completes.
|
||||||
*/
|
*/
|
||||||
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||||
const [thumbStats, setThumbStats] = useState<ThumbnailStats | null>(null)
|
const queryClient = useQueryClient()
|
||||||
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 [showAllErrors, setShowAllErrors] = useState(false)
|
const [showAllErrors, setShowAllErrors] = useState(false)
|
||||||
// One key per action so each button has its own spinner without
|
// One key per action so each button has its own spinner without
|
||||||
// blocking the others.
|
// blocking the others.
|
||||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const refreshStats = useCallback(async () => {
|
// All four panels fetch through React Query so cached data shows
|
||||||
setLoadingStats(true)
|
// instantly on reopen while a background refetch updates the numbers.
|
||||||
try {
|
// `enabled: isOpen` avoids firing requests while the dialog is closed,
|
||||||
const [thumbs, lib] = await Promise.all([
|
// but the cache entries survive between opens (default gcTime = 5m).
|
||||||
library.maintenance.thumbnailStats(),
|
const thumbStatsQuery = useQuery({
|
||||||
library.stats(),
|
queryKey: SETTINGS_THUMB_STATS_KEY,
|
||||||
])
|
queryFn: library.maintenance.thumbnailStats,
|
||||||
setThumbStats(thumbs)
|
enabled: isOpen,
|
||||||
setLibStats(lib)
|
// Treat as stale immediately so reopening the dialog triggers a
|
||||||
} catch (e) {
|
// background refetch on top of the cached view.
|
||||||
console.error('Failed to load settings stats', e)
|
staleTime: 0,
|
||||||
toast.error('Could not load library stats')
|
})
|
||||||
} finally {
|
const libStatsQuery = useQuery({
|
||||||
setLoadingStats(false)
|
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 () => {
|
const thumbStats = thumbStatsQuery.data
|
||||||
setLoadingWorkers(true)
|
const libStats = libStatsQuery.data
|
||||||
try {
|
const workerStatus = workerStatusQuery.data
|
||||||
const [ws, ms] = await Promise.all([
|
const missingStats = missingStatsQuery.data
|
||||||
library.maintenance.workerStatus(),
|
// "loading" in the UI sense = fetching AND no cached data yet. Background
|
||||||
library.maintenance.missingStats(),
|
// refetches on top of cached data shouldn't flip the refresh spinners.
|
||||||
])
|
const loadingStats =
|
||||||
setWorkerStatus(ws)
|
(thumbStatsQuery.isFetching && !thumbStatsQuery.data) ||
|
||||||
setMissingStats(ms)
|
(libStatsQuery.isFetching && !libStatsQuery.data)
|
||||||
} catch (e) {
|
const loadingWorkers =
|
||||||
console.error('Failed to load worker status', e)
|
(workerStatusQuery.isFetching && !workerStatusQuery.data) ||
|
||||||
toast.error('Could not load worker status')
|
(missingStatsQuery.isFetching && !missingStatsQuery.data)
|
||||||
} finally {
|
|
||||||
setLoadingWorkers(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Esc closes; load stats when opened. Workers section auto-polls
|
const refreshStats = useCallback(() => {
|
||||||
// every 5s while the dialog is open so the user sees live worker
|
queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY })
|
||||||
// activity without manually hammering the refresh button.
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
refreshStats()
|
|
||||||
refreshWorkers()
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
const poll = window.setInterval(() => {
|
return () => window.removeEventListener('keydown', handler)
|
||||||
refreshWorkers()
|
}, [isOpen, onClose])
|
||||||
}, 5000)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handler)
|
|
||||||
window.clearInterval(poll)
|
|
||||||
}
|
|
||||||
}, [isOpen, onClose, refreshStats, refreshWorkers])
|
|
||||||
|
|
||||||
const runAction = useCallback(
|
const runAction = useCallback(
|
||||||
async <T,>(
|
async <T,>(
|
||||||
@@ -117,7 +148,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await fn()
|
const result = await fn()
|
||||||
toast.success(successTitle, describe?.(result))
|
toast.success(successTitle, describe?.(result))
|
||||||
await Promise.all([refreshStats(), refreshWorkers()])
|
refreshStats()
|
||||||
|
refreshWorkers()
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e instanceof Error ? e.message : String(e)
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
toast.error(`${successTitle} failed`, message)
|
toast.error(`${successTitle} failed`, message)
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
RefreshCw,
|
|
||||||
Copy,
|
Copy,
|
||||||
Tag as TagIcon,
|
Tag as TagIcon,
|
||||||
Layers2,
|
Layers2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
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 { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
@@ -44,11 +44,11 @@ interface TreeItem {
|
|||||||
|
|
||||||
interface LeftSidebarProps {
|
interface LeftSidebarProps {
|
||||||
onCollapse: () => void
|
onCollapse: () => void
|
||||||
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
|
export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
|
||||||
// Inline rename state for source-root rows. Stores the id being edited
|
// Inline rename state for source-root rows. Stores the id being edited
|
||||||
// and the draft name. Double-click a folder row to start.
|
// and the draft name. Double-click a folder row to start.
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||||
@@ -309,30 +309,6 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
|
|||||||
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
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 toggleExpanded = (id: string) => {
|
||||||
const newExpanded = new Set(expandedItems)
|
const newExpanded = new Set(expandedItems)
|
||||||
if (newExpanded.has(id)) {
|
if (newExpanded.has(id)) {
|
||||||
@@ -698,19 +674,18 @@ export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
|
|||||||
<HeapsPanel />
|
<HeapsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Settings entry point — pinned to the bottom of the panel so it
|
||||||
{folderTree.length > 0 && (
|
* sits out of the way of the library tree but is always reachable. */}
|
||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleScanAll}
|
onClick={onOpenSettings}
|
||||||
disabled={isScanning}
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
title="Settings"
|
||||||
>
|
>
|
||||||
<RefreshCw className={clsx('h-4 w-4', isScanning && 'animate-spin')} />
|
<Settings className="h-4 w-4" />
|
||||||
{isScanning ? 'Scanning...' : 'Scan all folders'}
|
Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<DeleteFolderDialog
|
<DeleteFolderDialog
|
||||||
isOpen={!!deletingFolder}
|
isOpen={!!deletingFolder}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Settings, PanelLeftOpen, PanelRightOpen } from 'lucide-react'
|
import { PanelLeftOpen, PanelRightOpen } from 'lucide-react'
|
||||||
import muliLogo from '../../assets/muli-logo.png'
|
import muliLogo from '../../assets/muli-logo.png'
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
onOpenSettings: () => void
|
|
||||||
leftSidebarOpen: boolean
|
leftSidebarOpen: boolean
|
||||||
rightSidebarOpen: boolean
|
rightSidebarOpen: boolean
|
||||||
onExpandLeft: () => void
|
onExpandLeft: () => void
|
||||||
@@ -21,7 +20,6 @@ interface TopBarProps {
|
|||||||
* collapse twin lives in the panel's own header.
|
* collapse twin lives in the panel's own header.
|
||||||
*/
|
*/
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
onOpenSettings,
|
|
||||||
leftSidebarOpen,
|
leftSidebarOpen,
|
||||||
rightSidebarOpen,
|
rightSidebarOpen,
|
||||||
onExpandLeft,
|
onExpandLeft,
|
||||||
@@ -54,13 +52,6 @@ export function TopBar({
|
|||||||
<PanelRightOpen className="h-4 w-4" />
|
<PanelRightOpen className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Settings"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user