The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.
Detection
- New phash + duplicate_group_id columns on Photo, added via an
idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
decoded frame just before the destructive thumbnail loop. Falls back
silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
predated the column, reading the existing thumb_large rather than
re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
(threshold 6), persists duplicate_group_id, and maintains is_duplicate
as derived state so existing badges/counts keep working. Chained
after scan_all_source_roots with a 60s countdown.
API
- GET /library/duplicates/groups returns all groups with members,
bucketed in Python from one query. Each group has a reason ("exact"
iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.
Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
the timeline when the user is in the duplicates section. Each section
shows a "Keep best, discard N" button that picks the highest-pixel
copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
this" (Crown icon, top-right) to override the auto-pick. The header
annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
with ↑/↓ jumping by the measured column count and scrollIntoView on
every move. Timeline's keyboard handler now early-returns in the
duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
they don't collide visually with the cyan selection ring around a
selected cell. Dimensions chip moved to bottom-left to free both
right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
and exposes both backfill + re-detect actions, sharing a query cache
with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
836 lines
31 KiB
TypeScript
836 lines
31 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import {
|
|
X,
|
|
RefreshCw,
|
|
Wrench,
|
|
Film,
|
|
Image as ImageIcon,
|
|
AlertTriangle,
|
|
Database,
|
|
Loader2,
|
|
Cpu,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Copy,
|
|
Sparkles,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
library,
|
|
type MediaType,
|
|
} 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
|
|
// Shared with the DuplicatesView so a regroup invalidates the same cache
|
|
// the grid renders from. Imported via the canonical hook key.
|
|
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
|
|
|
|
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 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>>({})
|
|
|
|
// 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,
|
|
})
|
|
// Duplicates: shares the cache with DuplicatesView so a regroup
|
|
// triggered from Settings updates the grid view immediately.
|
|
const duplicatesQuery = useQuery({
|
|
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
|
|
queryFn: library.duplicates.groups,
|
|
enabled: isOpen,
|
|
staleTime: 0,
|
|
})
|
|
|
|
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)
|
|
|
|
const refreshStats = useCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: SETTINGS_THUMB_STATS_KEY })
|
|
queryClient.invalidateQueries({ queryKey: SETTINGS_LIB_STATS_KEY })
|
|
queryClient.invalidateQueries({ queryKey: DUPLICATE_GROUPS_QUERY_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
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [isOpen, onClose])
|
|
|
|
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))
|
|
refreshStats()
|
|
refreshWorkers()
|
|
} 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, refreshWorkers]
|
|
)
|
|
|
|
const regenerate = useCallback(
|
|
(
|
|
key: string,
|
|
body: {
|
|
media_types?: MediaType[]
|
|
only_failed?: boolean
|
|
only_pending?: 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>
|
|
|
|
{/* ----------------------------------------------------- */}
|
|
{/* Duplicate detection */}
|
|
{/* ----------------------------------------------------- */}
|
|
<Section
|
|
icon={<Copy className="h-4 w-4" />}
|
|
title="Duplicates"
|
|
>
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<Stat
|
|
label="Groups"
|
|
value={duplicatesQuery.data?.total_groups}
|
|
/>
|
|
<Stat
|
|
label="Members"
|
|
value={duplicatesQuery.data?.total_members}
|
|
/>
|
|
</div>
|
|
<p className="mt-3 text-xs text-text-muted">
|
|
Duplicates are detected by perceptual hash (pHash), which catches
|
|
visually-identical photos even when their bytes differ — re-encoded
|
|
JPEGs, screenshots, resized exports. Backfill computes hashes for
|
|
photos that existed before pHash was added; Re-detect re-runs
|
|
clustering across the whole library.
|
|
</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<ActionButton
|
|
loading={busy['backfill-phashes']}
|
|
onClick={() =>
|
|
runAction(
|
|
'backfill-phashes',
|
|
() => library.maintenance.backfillPhashes(),
|
|
'pHash backfill queued'
|
|
)
|
|
}
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
Backfill perceptual hashes
|
|
</ActionButton>
|
|
<ActionButton
|
|
loading={busy['regroup-duplicates']}
|
|
onClick={() =>
|
|
runAction(
|
|
'regroup-duplicates',
|
|
() => library.maintenance.regroupDuplicates(),
|
|
'Duplicate detection queued'
|
|
)
|
|
}
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
Re-detect duplicates
|
|
</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-pending']}
|
|
onClick={() =>
|
|
regenerate('regen-pending', { only_pending: true })
|
|
}
|
|
disabled={!!thumbStats && thumbStats.pending === 0}
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
Kick pending
|
|
{thumbStats ? ` (${thumbStats.pending})` : ''}
|
|
</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>
|
|
|
|
{/* ----------------------------------------------------- */}
|
|
{/* Worker fleet diagnostics */}
|
|
{/* ----------------------------------------------------- */}
|
|
<Section
|
|
icon={<Cpu className="h-4 w-4" />}
|
|
title="Workers"
|
|
right={
|
|
<button
|
|
onClick={refreshWorkers}
|
|
disabled={loadingWorkers}
|
|
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"
|
|
>
|
|
{loadingWorkers ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-3 w-3" />
|
|
)}
|
|
Refresh
|
|
</button>
|
|
}
|
|
>
|
|
{/* Top-line health */}
|
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
<Stat
|
|
label="Workers"
|
|
value={workerStatus?.worker_count}
|
|
tone={
|
|
workerStatus
|
|
? workerStatus.worker_count > 0
|
|
? 'ok'
|
|
: 'warn'
|
|
: 'muted'
|
|
}
|
|
/>
|
|
<Stat
|
|
label="Broker"
|
|
value={
|
|
workerStatus
|
|
? workerStatus.broker_ok
|
|
? 'OK'
|
|
: 'DOWN'
|
|
: undefined
|
|
}
|
|
tone={
|
|
workerStatus
|
|
? workerStatus.broker_ok
|
|
? 'ok'
|
|
: 'warn'
|
|
: 'muted'
|
|
}
|
|
/>
|
|
<Stat
|
|
label="Failed tasks"
|
|
value={workerStatus?.failures.total}
|
|
tone={
|
|
workerStatus && workerStatus.failures.total > 0
|
|
? 'warn'
|
|
: 'muted'
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Inline error banners for the obvious failure modes */}
|
|
{workerStatus?.broker_error && (
|
|
<ErrorBanner
|
|
title="Cannot reach Redis broker"
|
|
detail={workerStatus.broker_error}
|
|
/>
|
|
)}
|
|
{workerStatus?.inspect_error && (
|
|
<ErrorBanner
|
|
title="Celery inspect failed"
|
|
detail={workerStatus.inspect_error}
|
|
/>
|
|
)}
|
|
{workerStatus &&
|
|
workerStatus.broker_ok &&
|
|
workerStatus.worker_count === 0 && (
|
|
<ErrorBanner
|
|
title="No workers responding"
|
|
detail="Broker is reachable but no celery worker pinged back. Check the mulita-worker container logs."
|
|
/>
|
|
)}
|
|
|
|
{/* Queue depth */}
|
|
{workerStatus && (
|
|
<div className="mt-3">
|
|
<div className="mb-1 text-[10px] uppercase tracking-wide text-text-muted">
|
|
Queue depth
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
{Object.entries(workerStatus.queues).map(([name, depth]) => (
|
|
<div
|
|
key={name}
|
|
className="flex items-center justify-between rounded bg-surface px-2 py-1"
|
|
>
|
|
<span className="text-text-muted">{name}</span>
|
|
<span
|
|
className={clsx(
|
|
'font-mono font-semibold',
|
|
depth > 0 ? 'text-text' : 'text-text-muted'
|
|
)}
|
|
>
|
|
{depth}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Orphaned rows (files gone from disk) */}
|
|
{missingStats &&
|
|
((missingStats.would_delete ?? 0) > 0 ||
|
|
(missingStats.would_delete_folders ?? 0) > 0) && (
|
|
<div className="mt-3 rounded border border-star/40 bg-star/10 p-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-xs">
|
|
<div className="flex items-center gap-1.5 font-medium text-text">
|
|
<AlertTriangle className="h-3.5 w-3.5 text-star" />
|
|
Orphaned rows
|
|
</div>
|
|
<div className="mt-0.5 text-[10px] text-text-muted">
|
|
{missingStats.would_delete ?? 0} photos and{' '}
|
|
{missingStats.would_delete_folders ?? 0} folders
|
|
point at paths that no longer exist on disk under a
|
|
mounted source root. Usually means PHOTO_DIRS was
|
|
repointed at a different library.
|
|
{missingStats.skipped_unmounted > 0 && (
|
|
<>
|
|
{' '}
|
|
{missingStats.skipped_unmounted} more rows are
|
|
under unmounted roots and will not be touched.
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ActionButton
|
|
loading={busy['prune-missing']}
|
|
destructive
|
|
onClick={() => {
|
|
const photos = missingStats.would_delete ?? 0
|
|
const folders = missingStats.would_delete_folders ?? 0
|
|
if (
|
|
!confirm(
|
|
`Delete ${photos} photo rows and ${folders} folder rows whose paths are missing? ` +
|
|
'This cannot be undone.'
|
|
)
|
|
)
|
|
return
|
|
runAction(
|
|
'prune-missing',
|
|
() => library.maintenance.pruneMissing(),
|
|
'Orphans pruned',
|
|
(r) =>
|
|
`${r.deleted ?? 0} photos + ${r.deleted_folders ?? 0} folders deleted`
|
|
)
|
|
}}
|
|
>
|
|
<AlertTriangle className="h-4 w-4" />
|
|
Prune
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Per-worker breakdown */}
|
|
{workerStatus && workerStatus.workers.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
<div className="text-[10px] uppercase tracking-wide text-text-muted">
|
|
Worker fleet
|
|
</div>
|
|
{workerStatus.workers.map((w) => (
|
|
<div
|
|
key={w.name}
|
|
className="rounded border border-border bg-surface p-2 text-xs"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{w.status === 'online' ? (
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-pick" />
|
|
) : (
|
|
<AlertCircle className="h-3.5 w-3.5 text-reject" />
|
|
)}
|
|
<span className="font-mono text-text">{w.name}</span>
|
|
</div>
|
|
<span className="text-text-muted">
|
|
{w.active}/{w.concurrency ?? '?'} active
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-text-muted">
|
|
<span>reserved: {w.reserved}</span>
|
|
<span>scheduled: {w.scheduled}</span>
|
|
{w.queues.length > 0 && (
|
|
<span>queues: {w.queues.join(', ')}</span>
|
|
)}
|
|
</div>
|
|
{w.active_tasks.length > 0 && (
|
|
<div className="mt-1.5 space-y-0.5 border-t border-border pt-1.5">
|
|
{w.active_tasks.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
className="truncate font-mono text-[10px] text-text-muted"
|
|
title={`${t.name} ${JSON.stringify(t.args)}`}
|
|
>
|
|
<span className="text-text">{t.name}</span>{' '}
|
|
{Array.isArray(t.args)
|
|
? t.args.map((a) => String(a)).join(', ')
|
|
: ''}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent task failures */}
|
|
{workerStatus && workerStatus.failures.recent.length > 0 && (
|
|
<div className="mt-3">
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<div className="text-[10px] uppercase tracking-wide text-text-muted">
|
|
Recent failures ({workerStatus.failures.total})
|
|
</div>
|
|
{workerStatus.failures.recent.length > 5 && (
|
|
<button
|
|
onClick={() => setShowAllErrors((v) => !v)}
|
|
className="text-[10px] text-text-muted hover:text-text"
|
|
>
|
|
{showAllErrors ? 'Show less' : 'Show all'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="max-h-48 space-y-1 overflow-y-auto rounded border border-border bg-surface p-2">
|
|
{(showAllErrors
|
|
? workerStatus.failures.recent
|
|
: workerStatus.failures.recent.slice(0, 5)
|
|
).map((f) => (
|
|
<div
|
|
key={f.photo_id}
|
|
className="border-b border-border/40 pb-1 last:border-b-0 last:pb-0"
|
|
>
|
|
<div className="flex items-center justify-between gap-2 text-[11px]">
|
|
<span className="truncate font-mono text-text">
|
|
{f.filename}
|
|
</span>
|
|
<span className="shrink-0 text-[10px] text-text-muted">
|
|
{f.media_type}
|
|
</span>
|
|
</div>
|
|
<div className="break-all font-mono text-[10px] text-reject">
|
|
{f.error || '(no error message)'}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent scan errors (Redis list) */}
|
|
{workerStatus && workerStatus.scan_errors.length > 0 && (
|
|
<div className="mt-3">
|
|
<div className="mb-1 text-[10px] uppercase tracking-wide text-text-muted">
|
|
Scan errors
|
|
</div>
|
|
<div className="max-h-32 space-y-0.5 overflow-y-auto rounded border border-border bg-surface p-2 font-mono text-[10px] text-reject">
|
|
{workerStatus.scan_errors.map((e, i) => (
|
|
<div key={i} className="break-all">
|
|
{e}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!workerStatus && (
|
|
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Loading worker status…
|
|
</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 ErrorBanner({ title, detail }: { title: string; detail: string }) {
|
|
return (
|
|
<div className="mt-3 rounded border border-reject/40 bg-reject/10 p-2 text-xs">
|
|
<div className="flex items-center gap-1.5 font-medium text-reject">
|
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
{title}
|
|
</div>
|
|
<div className="mt-0.5 break-all font-mono text-[10px] text-reject/80">
|
|
{detail}
|
|
</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>
|
|
)
|
|
}
|