Compare commits
7 Commits
9ed577f40c
...
a4b1802657
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b1802657 | ||
|
|
ce25a4460e | ||
|
|
872be4e0cf | ||
|
|
d27ec1af2e | ||
|
|
697343646a | ||
|
|
3df8add3b6 | ||
|
|
42250aa16e |
@@ -142,6 +142,12 @@ class RegenerateThumbnailsRequest(BaseModel):
|
||||
default=False,
|
||||
description="If true, only re-queue photos whose processing_status is 'failed'.",
|
||||
)
|
||||
only_pending: bool = Field(
|
||||
default=False,
|
||||
description="If true, only (re-)queue photos whose processing_status is 'pending'. "
|
||||
"Useful for kicking rows that were created by a scan but never had "
|
||||
"their thumbnail task picked up.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/maintenance/thumbnail-stats")
|
||||
@@ -207,6 +213,8 @@ async def regenerate_thumbnails(
|
||||
query = query.where(Photo.media_type.in_(media_types))
|
||||
if body.only_failed:
|
||||
query = query.where(Photo.processing_status == 'failed')
|
||||
if body.only_pending:
|
||||
query = query.where(Photo.processing_status == 'pending')
|
||||
|
||||
photos = (await db.execute(query)).scalars().all()
|
||||
|
||||
@@ -252,6 +260,167 @@ async def regenerate_thumbnails(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/maintenance/worker-status")
|
||||
async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Diagnostics for the Celery worker fleet + recent task failures.
|
||||
|
||||
Surfaced in the Settings panel so the user can spot a stuck queue or
|
||||
a worker that's gone away without tailing container logs. Returns:
|
||||
|
||||
- workers: list of {name, status, active, concurrency, queues}
|
||||
derived from celery_app.control.inspect(). `status` is 'online'
|
||||
when ping succeeds, 'unreachable' otherwise. Empty list means no
|
||||
workers are responding at all (broker down, container crashed,
|
||||
wrong queue routing, etc.).
|
||||
- queues: per-queue depth read from Redis (LLEN of each queue key
|
||||
used by celery.kombu). Mirrors what tasks are waiting to be
|
||||
picked up.
|
||||
- failures: aggregate count of photos with processing_status='failed'
|
||||
plus the most recent N error messages so the user can see *why*
|
||||
things failed without opening the DB.
|
||||
- broker_ok: bool — could we even reach Redis?
|
||||
"""
|
||||
from app.tasks.celery import celery_app
|
||||
from app.config import settings
|
||||
import redis as _redis
|
||||
|
||||
# ----- Celery inspect (workers + active tasks) -------------------------
|
||||
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 {}
|
||||
|
||||
worker_names = set(ping) | set(active) | set(stats)
|
||||
for name in sorted(worker_names):
|
||||
wstats = stats.get(name) or {}
|
||||
pool = wstats.get('pool') or {}
|
||||
workers.append({
|
||||
"name": name,
|
||||
"status": "online" if name in ping else "unreachable",
|
||||
"active": len(active.get(name, []) or []),
|
||||
"reserved": len(reserved.get(name, []) or []),
|
||||
"scheduled": len(scheduled.get(name, []) or []),
|
||||
"concurrency": pool.get('max-concurrency'),
|
||||
"processed": (wstats.get('total') or {}),
|
||||
"queues": [q.get('name') for q in (active_queues.get(name) or [])],
|
||||
"active_tasks": [
|
||||
{
|
||||
"id": t.get('id'),
|
||||
"name": t.get('name'),
|
||||
"args": t.get('args'),
|
||||
"time_start": t.get('time_start'),
|
||||
}
|
||||
for t in (active.get(name) or [])[:10]
|
||||
],
|
||||
})
|
||||
except Exception as e:
|
||||
inspect_error = str(e)
|
||||
logger.warning(f"Celery inspect failed: {e}")
|
||||
|
||||
# ----- Broker / queue depth --------------------------------------------
|
||||
broker_ok = False
|
||||
queue_depths: dict[str, int] = {}
|
||||
broker_error: Optional[str] = None
|
||||
try:
|
||||
r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
|
||||
r.ping()
|
||||
broker_ok = True
|
||||
for q in ('default', 'high', 'low'):
|
||||
try:
|
||||
queue_depths[q] = int(r.llen(q) or 0)
|
||||
except Exception:
|
||||
queue_depths[q] = 0
|
||||
except Exception as e:
|
||||
broker_error = str(e)
|
||||
logger.warning(f"Redis broker unreachable: {e}")
|
||||
|
||||
# ----- Recent task failures from the photos table ----------------------
|
||||
failed_total = (
|
||||
await db.execute(
|
||||
select(func.count(Photo.id)).where(Photo.processing_status == 'failed')
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
recent_failed_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Photo.id,
|
||||
Photo.filename,
|
||||
Photo.media_type,
|
||||
Photo.processing_error,
|
||||
Photo.updated_at,
|
||||
)
|
||||
.where(Photo.processing_status == 'failed')
|
||||
.order_by(Photo.updated_at.desc().nullslast())
|
||||
.limit(20)
|
||||
)
|
||||
).all()
|
||||
|
||||
recent_failures = [
|
||||
{
|
||||
"photo_id": row[0],
|
||||
"filename": row[1],
|
||||
"media_type": row[2],
|
||||
"error": (row[3] or '')[:500],
|
||||
"updated_at": row[4].isoformat() if row[4] else None,
|
||||
}
|
||||
for row in recent_failed_rows
|
||||
]
|
||||
|
||||
# ----- Most recent scan errors (Redis list) ----------------------------
|
||||
scan_errors: list[str] = []
|
||||
try:
|
||||
if broker_ok:
|
||||
r = _redis.Redis.from_url(settings.redis_url, socket_timeout=1.0)
|
||||
raw = r.lrange('scan:errors', 0, 19) or []
|
||||
scan_errors = [e.decode(errors='replace') for e in raw]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read scan:errors: {e}")
|
||||
|
||||
return {
|
||||
"broker_ok": broker_ok,
|
||||
"broker_error": broker_error,
|
||||
"inspect_error": inspect_error,
|
||||
"workers": workers,
|
||||
"worker_count": len(workers),
|
||||
"queues": queue_depths,
|
||||
"failures": {
|
||||
"total": failed_total,
|
||||
"recent": recent_failures,
|
||||
},
|
||||
"scan_errors": scan_errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/maintenance/missing-stats")
|
||||
async def get_missing_stats():
|
||||
"""Count photos whose files no longer exist on disk under a mounted
|
||||
source root. Surfaced in Settings so the user can see a number before
|
||||
pulling the trigger on prune-missing. Cheap enough to call freely."""
|
||||
from app.services.cleanup import prune_missing_photos
|
||||
return await prune_missing_photos(dry_run=True)
|
||||
|
||||
|
||||
@router.post("/maintenance/prune-missing")
|
||||
async def run_prune_missing():
|
||||
"""Actually delete the orphaned photo rows reported by /missing-stats.
|
||||
Common cause: PHOTO_DIRS in .env was repointed at a different library
|
||||
leaving every old row dangling. Skips any photo whose source root
|
||||
isn't currently mounted (almost always means an unmounted drive)."""
|
||||
from app.services.cleanup import prune_missing_photos
|
||||
try:
|
||||
return {"status": "success", **(await prune_missing_photos(dry_run=False))}
|
||||
except Exception as e:
|
||||
logger.error(f"Prune missing failed: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/maintenance/cleanup")
|
||||
async def run_data_integrity_cleanup():
|
||||
"""Re-run the source-roots / folders / photos data-integrity cleanup
|
||||
|
||||
@@ -139,6 +139,118 @@ async def _warn_stale_source_roots(session: AsyncSession) -> int:
|
||||
return stale
|
||||
|
||||
|
||||
async def find_missing(
|
||||
session: AsyncSession,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Walk every non-discarded photo + every folder and check whether
|
||||
they still resolve on disk. Returns
|
||||
(deletable_photo_ids, deletable_folder_ids, skipped_photo_ids).
|
||||
|
||||
Skipped rows are photos/folders whose owning source_root path itself
|
||||
doesn't resolve — that's almost always an unmounted drive, and
|
||||
silently deleting those rows would be data loss. The caller can
|
||||
surface the skip count separately so the user knows the cleanup
|
||||
wasn't a no-op by accident.
|
||||
"""
|
||||
sr_rows = (await session.execute(select(SourceRoot))).scalars().all()
|
||||
sr_mounted: dict[str, bool] = {sr.id: os.path.isdir(sr.path) for sr in sr_rows}
|
||||
|
||||
photos = (await session.execute(
|
||||
select(Photo.id, Photo.filepath, Photo.folder_id)
|
||||
.where(Photo.is_discarded.is_(False))
|
||||
)).all()
|
||||
|
||||
folders = (await session.execute(
|
||||
select(Folder.id, Folder.path, Folder.source_root_id)
|
||||
)).all()
|
||||
folder_to_sr = {fid: srid for fid, _path, srid in folders}
|
||||
|
||||
deletable_photos: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for pid, fp, folder_id in photos:
|
||||
sr_id = folder_to_sr.get(folder_id)
|
||||
if sr_id is None or not sr_mounted.get(sr_id, False):
|
||||
skipped.append(pid)
|
||||
continue
|
||||
if not os.path.exists(fp):
|
||||
deletable_photos.append(pid)
|
||||
|
||||
deletable_folders: list[str] = []
|
||||
for fid, fpath, sr_id in folders:
|
||||
if sr_id is None or not sr_mounted.get(sr_id, False):
|
||||
continue
|
||||
if not os.path.isdir(fpath):
|
||||
deletable_folders.append(fid)
|
||||
|
||||
return deletable_photos, deletable_folders, skipped
|
||||
|
||||
|
||||
async def prune_missing_photos(dry_run: bool = True) -> dict:
|
||||
"""Delete photo + folder rows whose paths are no longer on disk *and*
|
||||
whose source root is currently mounted. Common cause: PHOTO_DIRS in
|
||||
.env was repointed at a different library, leaving every old row
|
||||
orphaned.
|
||||
|
||||
Set dry_run=False to actually delete. The default is intentionally
|
||||
safe so the matching count can be surfaced in the UI before the
|
||||
user commits to it.
|
||||
|
||||
Function name kept for backwards compatibility — it now also prunes
|
||||
folders, not just photos.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
deletable_photos, deletable_folders, skipped = await find_missing(session)
|
||||
if not dry_run:
|
||||
CHUNK = 500
|
||||
# Photos first (folders may FK from them via folder_id).
|
||||
for i in range(0, len(deletable_photos), CHUNK):
|
||||
await session.execute(
|
||||
delete(Photo).where(
|
||||
Photo.id.in_(deletable_photos[i:i + CHUNK])
|
||||
)
|
||||
)
|
||||
# Then drop folders that ALSO no longer have any photos
|
||||
# pointing at them. We re-check after the photo delete so
|
||||
# we don't strand a folder that legitimately exists on
|
||||
# disk but happened to match the orphan list.
|
||||
if deletable_folders:
|
||||
for i in range(0, len(deletable_folders), CHUNK):
|
||||
chunk = deletable_folders[i:i + CHUNK]
|
||||
# Only delete folders that now have zero photos
|
||||
# left attached (defensive — should always be 0
|
||||
# if the path is gone, but a concurrent scan
|
||||
# could re-create rows).
|
||||
still_used = (await session.execute(
|
||||
select(Photo.folder_id)
|
||||
.where(Photo.folder_id.in_(chunk))
|
||||
.distinct()
|
||||
)).scalars().all()
|
||||
safe = [f for f in chunk if f not in set(still_used)]
|
||||
if safe:
|
||||
await session.execute(
|
||||
delete(Folder).where(Folder.id.in_(safe))
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Pruned {len(deletable_photos)} photo rows + "
|
||||
f"{len(deletable_folders)} folder rows"
|
||||
)
|
||||
key_p = "would_delete" if dry_run else "deleted"
|
||||
key_f = "would_delete_folders" if dry_run else "deleted_folders"
|
||||
return {
|
||||
key_p: len(deletable_photos),
|
||||
key_f: len(deletable_folders),
|
||||
"skipped_unmounted": len(skipped),
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"prune_missing_photos failed: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def cleanup_data_integrity() -> dict:
|
||||
"""Top-level entry point. Runs the dedupe + count refresh in a single
|
||||
transaction. Returns a small summary dict for logging."""
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import aiofiles
|
||||
import redis
|
||||
@@ -172,10 +172,21 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
||||
# Calculate file hash for duplicate detection
|
||||
file_hash = await calculate_file_hash(filepath)
|
||||
|
||||
# Check for duplicate by hash
|
||||
duplicate = await session.execute(
|
||||
select(Photo).where(Photo.file_hash == file_hash)
|
||||
) if file_hash else None
|
||||
# Check for duplicate by hash. We only care
|
||||
# whether *any* other photo shares this hash, so
|
||||
# use a count rather than scalar_one_or_none()
|
||||
# which raises "Multiple rows were found" the
|
||||
# moment the library has 2+ copies of the same
|
||||
# file (i.e. exactly the case we're trying to
|
||||
# flag).
|
||||
is_dup = False
|
||||
if file_hash:
|
||||
dup_count = (await session.execute(
|
||||
select(func.count(Photo.id)).where(
|
||||
Photo.file_hash == file_hash
|
||||
)
|
||||
)).scalar() or 0
|
||||
is_dup = dup_count > 0
|
||||
|
||||
# Create photo entry
|
||||
photo = Photo(
|
||||
@@ -188,7 +199,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
||||
file_size=stat.st_size,
|
||||
taken_at=datetime.fromtimestamp(stat.st_mtime),
|
||||
taken_at_source='filesystem',
|
||||
is_duplicate=bool(duplicate.scalar_one_or_none() if duplicate else False),
|
||||
is_duplicate=is_dup,
|
||||
processing_status='pending'
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,18 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
# Never cache index.html (or any HTML). The asset filenames are
|
||||
# content-hashed by Vite, so a fresh index.html is the only thing
|
||||
# that tells the browser to fetch the new bundle. Without this the
|
||||
# browser happily serves a stale index.html → stale bundle hash →
|
||||
# users see the old build until they hard-reload.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# Cache static assets (filenames are content-hashed, so 1y is safe)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Loader2,
|
||||
Cpu,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
@@ -15,6 +18,8 @@ import {
|
||||
type ThumbnailStats,
|
||||
type LibraryStats,
|
||||
type MediaType,
|
||||
type WorkerStatus,
|
||||
type MissingStats,
|
||||
} from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
|
||||
@@ -37,7 +42,11 @@ interface SettingsDialogProps {
|
||||
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 [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>>({})
|
||||
@@ -59,16 +68,42 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Esc closes; load stats when opened.
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
refreshStats()
|
||||
refreshWorkers()
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [isOpen, onClose, refreshStats])
|
||||
const poll = window.setInterval(() => {
|
||||
refreshWorkers()
|
||||
}, 5000)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler)
|
||||
window.clearInterval(poll)
|
||||
}
|
||||
}, [isOpen, onClose, refreshStats, refreshWorkers])
|
||||
|
||||
const runAction = useCallback(
|
||||
async <T,>(
|
||||
@@ -82,7 +117,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
try {
|
||||
const result = await fn()
|
||||
toast.success(successTitle, describe?.(result))
|
||||
await refreshStats()
|
||||
await Promise.all([refreshStats(), refreshWorkers()])
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
toast.error(`${successTitle} failed`, message)
|
||||
@@ -90,11 +125,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
setBusy((b) => ({ ...b, [key]: false }))
|
||||
}
|
||||
},
|
||||
[busy, refreshStats]
|
||||
[busy, refreshStats, refreshWorkers]
|
||||
)
|
||||
|
||||
const regenerate = useCallback(
|
||||
(key: string, body: { media_types?: MediaType[]; only_failed?: boolean }) =>
|
||||
(
|
||||
key: string,
|
||||
body: {
|
||||
media_types?: MediaType[]
|
||||
only_failed?: boolean
|
||||
only_pending?: boolean
|
||||
}
|
||||
) =>
|
||||
runAction(
|
||||
key,
|
||||
() => library.maintenance.regenerateThumbnails(body),
|
||||
@@ -234,6 +276,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
{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
|
||||
@@ -254,6 +308,290 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</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 */}
|
||||
{/* ----------------------------------------------------- */}
|
||||
@@ -345,6 +683,20 @@ function Stat({
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -17,6 +17,13 @@ const AUTO_RETRY_DELAYS = [1500, 3500, 7000, 12000, 20000]
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
size: number
|
||||
/** When true, the cell stretches to fill its parent (100% width +
|
||||
* 100% height) and ignores `size` for the box dimensions. Used by
|
||||
* the Timeline grid where the parent is a CSS grid track of 1fr —
|
||||
* this is what guarantees the row fills the container without any
|
||||
* rounding gap on the right. The heap sidebar leaves this off so
|
||||
* thumbnails stay at the explicit `size`. */
|
||||
fill?: boolean
|
||||
isSelected: boolean
|
||||
/** True when the photo belongs to the currently active heap. */
|
||||
isInActiveHeap?: boolean
|
||||
@@ -30,6 +37,7 @@ interface PhotoThumbnailProps {
|
||||
export function PhotoThumbnail({
|
||||
photo,
|
||||
size,
|
||||
fill = false,
|
||||
isSelected,
|
||||
isInActiveHeap = false,
|
||||
activeHeapName = null,
|
||||
@@ -51,6 +59,12 @@ export function PhotoThumbnail({
|
||||
// overflowed their row because TanStack Virtual estimates row height as a
|
||||
// single fixed value — portraits in a landscape row would overlap the row
|
||||
// below. With object-cover the image still fills the cell, just cropped.
|
||||
//
|
||||
// The cell stretches to whatever width the parent grid track gives it
|
||||
// (via width:100% + aspect-ratio:1) so the timeline's CSS grid can hand
|
||||
// out 1fr columns and we never leave horizontal space unused. `size`
|
||||
// remains the *minimum* track width and the fallback when there's no
|
||||
// parent grid (e.g. heap thumbnails).
|
||||
const displayHeight = size
|
||||
|
||||
const clearRetryTimer = () => {
|
||||
@@ -131,10 +145,11 @@ export function PhotoThumbnail({
|
||||
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
|
||||
!imageLoaded && 'bg-surface animate-pulse'
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: displayHeight,
|
||||
}}
|
||||
style={
|
||||
fill
|
||||
? { width: '100%', height: '100%' }
|
||||
: { width: size, height: displayHeight }
|
||||
}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
draggable
|
||||
|
||||
@@ -35,6 +35,7 @@ type TimelineItem =
|
||||
function buildItems(
|
||||
photos: Photo[],
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
sortBy: string,
|
||||
groupBy: 'date' | 'tag'
|
||||
): TimelineItem[] {
|
||||
@@ -50,7 +51,7 @@ function buildItems(
|
||||
type: 'row',
|
||||
key: `${groupKey}::row::${i}`,
|
||||
cells: slice,
|
||||
height: THUMBNAIL_SIZE + GAP,
|
||||
height: rowHeight + GAP,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -161,6 +162,12 @@ function buildItems(
|
||||
|
||||
export function Timeline() {
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
// Sentinel placed inside the inner virtualizer wrapper at the exact
|
||||
// position rows will render. We measure THIS instead of parentRef,
|
||||
// because parentRef has padding and we'd otherwise have to subtract
|
||||
// it (and account for any scrollbar) — easy to get wrong by a pixel
|
||||
// and end up with a column count off by one.
|
||||
const widthSentinelRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
const {
|
||||
@@ -180,13 +187,33 @@ export function Timeline() {
|
||||
const groupBy = useFilterStore((s) => s.groupBy)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
|
||||
// Calculate number of columns based on container width.
|
||||
const columns = useMemo(() => {
|
||||
if (containerWidth === 0) return 4
|
||||
return Math.max(
|
||||
// Calculate number of columns + actual cell size based on container
|
||||
// width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to
|
||||
// fill the remaining space, so we never leave a horizontal gap on
|
||||
// the right side of the grid.
|
||||
//
|
||||
// Column math: with N columns there are N-1 inter-cell gaps, so the
|
||||
// width needed is N*T + (N-1)*G. Solving for the largest N that fits
|
||||
// in the available width gives N = floor((available + G) / (T + G)).
|
||||
// The previous formula floor((available) / (T + G)) was off-by-one
|
||||
// and lost a whole column whenever the remainder almost fit.
|
||||
const { columns, cellSize } = useMemo(() => {
|
||||
if (containerWidth === 0) {
|
||||
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
||||
}
|
||||
// containerWidth here is the sentinel's actual rendered width — no
|
||||
// padding subtraction needed, the sentinel already lives inside the
|
||||
// padded scroll container.
|
||||
const available = containerWidth
|
||||
const cols = Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth - PADDING * 2) / (THUMBNAIL_SIZE + GAP))
|
||||
Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP))
|
||||
)
|
||||
// Exact float — no floor. cellSize × cols + (cols-1) × gap == available
|
||||
// by construction, so the row fills edge-to-edge without any
|
||||
// sub-pixel rounding gap.
|
||||
const cell = (available - (cols - 1) * GAP) / cols
|
||||
return { columns: cols, cellSize: cell }
|
||||
}, [containerWidth])
|
||||
|
||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||
@@ -203,8 +230,8 @@ export function Timeline() {
|
||||
// photos. Date headers appear when sorted by a date field; tag headers
|
||||
// appear when groupBy === 'tag' (overrides date grouping).
|
||||
const items = useMemo(
|
||||
() => buildItems(photos, columns, sortBy, groupBy),
|
||||
[photos, columns, sortBy, groupBy]
|
||||
() => buildItems(photos, columns, cellSize, sortBy, groupBy),
|
||||
[photos, columns, cellSize, sortBy, groupBy]
|
||||
)
|
||||
|
||||
// Pre-computed offset of every header in the virtualizer's coordinate
|
||||
@@ -225,7 +252,7 @@ export function Timeline() {
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index) => items[index]?.height ?? THUMBNAIL_SIZE,
|
||||
estimateSize: (index) => items[index]?.height ?? cellSize,
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
@@ -264,16 +291,22 @@ export function Timeline() {
|
||||
return current
|
||||
}, [headerOffsets, scrollTop])
|
||||
|
||||
// Measure container width on mount and resize.
|
||||
// Measure the sentinel's actual rendered width on mount, window
|
||||
// resize, and any layout change driven by the sidebar collapse /
|
||||
// right panel toggle. ResizeObserver picks up everything window
|
||||
// resize misses (sidebar collapse doesn't fire window resize).
|
||||
useEffect(() => {
|
||||
const measureWidth = () => {
|
||||
if (parentRef.current) {
|
||||
setContainerWidth(parentRef.current.clientWidth)
|
||||
}
|
||||
const el = widthSentinelRef.current
|
||||
if (!el) return
|
||||
const measure = () => setContainerWidth(el.clientWidth)
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
window.addEventListener('resize', measure)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', measure)
|
||||
}
|
||||
measureWidth()
|
||||
window.addEventListener('resize', measureWidth)
|
||||
return () => window.removeEventListener('resize', measureWidth)
|
||||
}, [])
|
||||
|
||||
// Photo rows in visual order — drops the header items so navigation
|
||||
@@ -465,6 +498,23 @@ export function Timeline() {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Width sentinel — a 1px-tall normal-flow div that takes the
|
||||
* full width of the inner virtualizer wrapper, which is the
|
||||
* exact width rows render at. clientWidth on this is what we
|
||||
* base the column count on, sidestepping any padding /
|
||||
* scrollbar mismatch the parentRef-based measurement is
|
||||
* vulnerable to. ResizeObserver doesn't reliably fire on
|
||||
* zero-area absolute elements, so 1px tall + relative flow. */}
|
||||
<div
|
||||
ref={widthSentinelRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 1,
|
||||
marginBottom: -1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const item = items[virtualItem.index]
|
||||
if (!item) return null
|
||||
@@ -503,12 +553,27 @@ export function Timeline() {
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex" style={{ gap: `${GAP}px` }}>
|
||||
<div
|
||||
style={{
|
||||
// Fixed-size grid: every track is exactly cellSize
|
||||
// wide and the row is exactly cellSize tall, so
|
||||
// cells are guaranteed square no matter what CSS
|
||||
// the cell contents bring along. cellSize was
|
||||
// already computed from `available / cols` so the
|
||||
// sum cols*cellSize + (cols-1)*gap equals the
|
||||
// container width to within sub-pixel rounding.
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
|
||||
gridAutoRows: `${cellSize}px`,
|
||||
gap: `${GAP}px`,
|
||||
}}
|
||||
>
|
||||
{item.cells.map(({ photo }) => (
|
||||
<PhotoThumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
size={THUMBNAIL_SIZE}
|
||||
size={cellSize}
|
||||
fill
|
||||
isSelected={selectedPhotos.includes(photo.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||
activeHeapName={activeHeapName}
|
||||
|
||||
@@ -53,17 +53,33 @@ export function usePhotosQuery() {
|
||||
// Goes through the shared axios instance so it inherits the
|
||||
// relative /api/v1 baseURL — same-origin behind the nginx / vite
|
||||
// proxy, no CORS dance required from another machine.
|
||||
const response = await api.get<{ photos: Photo[]; total: number }>(
|
||||
'/photos',
|
||||
{
|
||||
//
|
||||
// The Timeline and grid views virtualize, so we load every match
|
||||
// up-front rather than paginating in the UI. Backend caps per_page
|
||||
// at 500, so for libraries / folders with more matches we walk
|
||||
// pages until we have everything. Capped at 200 pages (= 100k
|
||||
// photos) as a sanity bound.
|
||||
const PER_PAGE = 500
|
||||
const MAX_PAGES = 200
|
||||
const all: Photo[] = []
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const response = await api.get<{
|
||||
photos: Photo[]
|
||||
total: number
|
||||
pages: number
|
||||
}>('/photos', {
|
||||
params: {
|
||||
page: 1,
|
||||
per_page: 500,
|
||||
page,
|
||||
per_page: PER_PAGE,
|
||||
...filterParams,
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data.photos || []
|
||||
})
|
||||
const photos = response.data.photos || []
|
||||
all.push(...photos)
|
||||
const totalPages = response.data.pages ?? 1
|
||||
if (page >= totalPages || photos.length < PER_PAGE) break
|
||||
}
|
||||
return all
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
@@ -230,6 +230,59 @@ export interface RegenerateResult {
|
||||
}
|
||||
}
|
||||
|
||||
export interface MissingStats {
|
||||
would_delete?: number
|
||||
deleted?: number
|
||||
would_delete_folders?: number
|
||||
deleted_folders?: number
|
||||
skipped_unmounted: number
|
||||
dry_run: boolean
|
||||
}
|
||||
|
||||
export interface PruneResult extends MissingStats {
|
||||
status?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface WorkerInfo {
|
||||
name: string
|
||||
status: 'online' | 'unreachable'
|
||||
active: number
|
||||
reserved: number
|
||||
scheduled: number
|
||||
concurrency: number | null
|
||||
processed: Record<string, number>
|
||||
queues: string[]
|
||||
active_tasks: Array<{
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
time_start: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface WorkerFailure {
|
||||
photo_id: string
|
||||
filename: string
|
||||
media_type: string
|
||||
error: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface WorkerStatus {
|
||||
broker_ok: boolean
|
||||
broker_error: string | null
|
||||
inspect_error: string | null
|
||||
workers: WorkerInfo[]
|
||||
worker_count: number
|
||||
queues: Record<string, number>
|
||||
failures: {
|
||||
total: number
|
||||
recent: WorkerFailure[]
|
||||
}
|
||||
scan_errors: string[]
|
||||
}
|
||||
|
||||
export const library = {
|
||||
scan: async () => {
|
||||
const response = await api.post('/library/scan')
|
||||
@@ -256,7 +309,11 @@ export const library = {
|
||||
/** Reset on-disk thumbs and re-queue Celery generation. With no
|
||||
* filters, every photo in the library is re-queued. */
|
||||
regenerateThumbnails: async (
|
||||
body: { media_types?: MediaType[]; only_failed?: boolean } = {}
|
||||
body: {
|
||||
media_types?: MediaType[]
|
||||
only_failed?: boolean
|
||||
only_pending?: boolean
|
||||
} = {}
|
||||
): Promise<RegenerateResult> => {
|
||||
const response = await api.post(
|
||||
'/library/maintenance/regenerate-thumbnails',
|
||||
@@ -265,6 +322,27 @@ export const library = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Celery worker fleet diagnostics + recent task failures. Surfaced
|
||||
* in the Settings panel so users can debug stuck queues without
|
||||
* tailing container logs. */
|
||||
workerStatus: async (): Promise<WorkerStatus> => {
|
||||
const response = await api.get('/library/maintenance/worker-status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Dry-run count of photo rows whose files are no longer on disk
|
||||
* (under a mounted source root). */
|
||||
missingStats: async (): Promise<MissingStats> => {
|
||||
const response = await api.get('/library/maintenance/missing-stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Actually delete the orphaned photo rows. */
|
||||
pruneMissing: async (): Promise<PruneResult> => {
|
||||
const response = await api.post('/library/maintenance/prune-missing')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Re-run the source-roots / folders / photos integrity cleanup that
|
||||
* normally runs on backend startup. */
|
||||
cleanup: async (): Promise<{ status: string; message?: string }> => {
|
||||
|
||||
Reference in New Issue
Block a user