feat: settings panel + thumbnail pipeline fixes
- photos.py: stop crashing in FileResponse when a thumb hasn't been generated; return a clean 404 with Retry-After so the frontend can back off. - thumbs.py: fix process_video_thumbnail (overwrite_output, robust duration probe across stream/format, eager frame load + temp cleanup) so videos stop ending up as the gray placeholder. - library.py: new /maintenance/* endpoints — thumbnail-stats, regenerate-thumbnails (with media_type / only_failed filters), and a manual data-integrity cleanup trigger. - Frontend Settings panel (gear in TopBar) surfacing those endpoints plus a re-scan button and live thumbnail status counts. - PhotoThumbnail: stretch the auto-retry schedule for slow RAW jobs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
383
frontend/src/components/dialogs/SettingsDialog.tsx
Normal file
383
frontend/src/components/dialogs/SettingsDialog.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
library,
|
||||
type ThumbnailStats,
|
||||
type LibraryStats,
|
||||
type MediaType,
|
||||
} from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
|
||||
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 [thumbStats, setThumbStats] = useState<ThumbnailStats | null>(null)
|
||||
const [libStats, setLibStats] = useState<LibraryStats | null>(null)
|
||||
const [loadingStats, setLoadingStats] = 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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Esc closes; load stats when opened.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
refreshStats()
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isOpen, onClose, refreshStats])
|
||||
|
||||
const runAction = useCallback(
|
||||
async <T,>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
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))
|
||||
await refreshStats()
|
||||
} 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]
|
||||
)
|
||||
|
||||
const regenerate = useCallback(
|
||||
(key: string, body: { media_types?: MediaType[]; only_failed?: 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 (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="relative z-10 flex max-h-[85vh] w-[640px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 className="text-base font-semibold text-text">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{/* ----------------------------------------------------- */}
|
||||
{/* Library overview */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
<Section
|
||||
icon={<Database className="h-4 w-4" />}
|
||||
title="Library"
|
||||
right={
|
||||
<button
|
||||
onClick={refreshStats}
|
||||
disabled={loadingStats}
|
||||
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{loadingStats ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<Stat label="Photos" value={libStats?.total_photos} />
|
||||
<Stat label="Videos" value={libStats?.total_videos} />
|
||||
<Stat
|
||||
label="On disk"
|
||||
value={
|
||||
libStats ? `${libStats.total_size_gb.toFixed(1)} GB` : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ActionButton
|
||||
loading={busy.scan}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
'scan',
|
||||
() => library.scan(),
|
||||
'Library scan started'
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Re-scan source folders
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------- */}
|
||||
{/* Thumbnail maintenance */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
<Section
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
title="Thumbnails"
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
<Stat
|
||||
label="Completed"
|
||||
value={thumbStats?.completed}
|
||||
tone="ok"
|
||||
/>
|
||||
<Stat
|
||||
label="Pending"
|
||||
value={thumbStats?.pending}
|
||||
tone="muted"
|
||||
/>
|
||||
<Stat
|
||||
label="Processing"
|
||||
value={thumbStats?.processing}
|
||||
tone="muted"
|
||||
/>
|
||||
<Stat
|
||||
label="Failed"
|
||||
value={thumbStats?.failed}
|
||||
tone={thumbStats && thumbStats.failed > 0 ? 'warn' : 'muted'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-text-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.
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<ActionButton
|
||||
loading={busy['regen-videos']}
|
||||
onClick={() =>
|
||||
regenerate('regen-videos', { media_types: ['video'] })
|
||||
}
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
Regenerate video thumbnails
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
loading={busy['regen-failed']}
|
||||
onClick={() =>
|
||||
regenerate('regen-failed', { only_failed: true })
|
||||
}
|
||||
disabled={!!thumbStats && thumbStats.failed === 0}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Retry failed
|
||||
{thumbStats ? ` (${thumbStats.failed})` : ''}
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
loading={busy['regen-all']}
|
||||
destructive
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
'Regenerate thumbnails for the entire library? ' +
|
||||
'This will queue every photo and may take a while.'
|
||||
)
|
||||
)
|
||||
return
|
||||
regenerate('regen-all', {})
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Regenerate all
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------- */}
|
||||
{/* Data integrity */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
<Section
|
||||
icon={<Wrench className="h-4 w-4" />}
|
||||
title="Maintenance"
|
||||
>
|
||||
<p className="text-xs text-text-muted">
|
||||
Re-runs the source-roots / folders / photos integrity
|
||||
cleanup that normally only runs on backend startup. Safe
|
||||
to run any time.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<ActionButton
|
||||
loading={busy.cleanup}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
'cleanup',
|
||||
() => library.maintenance.cleanup(),
|
||||
'Data integrity cleanup complete'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
Run data integrity cleanup
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<section className="mb-5 last:mb-0">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-text">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
{right}
|
||||
</div>
|
||||
<div className="rounded border border-border bg-bg p-3">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded bg-surface p-2">
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
{label}
|
||||
</div>
|
||||
<div className={clsx('text-base font-semibold', toneClass)}>
|
||||
{value ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
loading,
|
||||
disabled,
|
||||
destructive,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
destructive?: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
destructive
|
||||
? 'border-reject/40 text-reject hover:bg-reject/10'
|
||||
: 'border-border text-text hover:bg-surface-2',
|
||||
(disabled || loading) && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{!loading && children}
|
||||
{loading && (
|
||||
// Re-render only the textual children when loading by stripping
|
||||
// the icon (children[0]) — we keep just the label so the spinner
|
||||
// takes the icon slot.
|
||||
Array.isArray(children) ? children.slice(1) : children
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
import { Settings } from 'lucide-react'
|
||||
import muliLogo from '../../assets/muli-logo.png'
|
||||
|
||||
interface TopBarProps {
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim top bar — just the logo. The active heap badge moved into the
|
||||
* Heaps panel in the left sidebar (where it actually relates to the
|
||||
* heap rows the user navigates to).
|
||||
* Slim top bar — logo on the left, settings gear on the right. The
|
||||
* active heap badge moved into the Heaps panel in the left sidebar
|
||||
* (where it actually relates to the heap rows the user navigates to).
|
||||
*/
|
||||
export function TopBar() {
|
||||
export function TopBar({ onOpenSettings }: TopBarProps) {
|
||||
return (
|
||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
||||
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" />
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import { usePhotoStore } from '../../store/photoStore'
|
||||
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
||||
|
||||
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
||||
// first hit often 404s. Try a few times with backoff before giving up.
|
||||
const AUTO_RETRY_DELAYS = [1500, 3500, 6000]
|
||||
// the first hit on a freshly-scanned library returns 404 "not ready" until
|
||||
// the worker catches up. RAW postprocess can take several seconds per file
|
||||
// when the queue is deep, so the tail of the schedule is generous.
|
||||
const AUTO_RETRY_DELAYS = [1500, 3500, 7000, 12000, 20000]
|
||||
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
|
||||
Reference in New Issue
Block a user