7 Commits

Author SHA1 Message Date
root
a4b1802657 fix: square timeline cells that fully fill each row
The grid was leaving horizontal space unused for two reasons:

1. Width measurement was based on parentRef.clientWidth - PADDING*2,
   which is fragile to padding/box-sizing/scrollbar mismatches and was
   off by enough pixels in practice to drop a column. Replace with a
   1px-tall normal-flow sentinel rendered inside the inner virtualizer
   wrapper at the exact horizontal extent rows render at. ResizeObserver
   on the sentinel gives the authoritative row width — no padding
   subtraction, no scrollbar guesswork.

2. The flex layout with explicit per-cell px widths accumulated floor()
   rounding error and let cells drift away from square. Switch to a CSS
   grid with fixed-px tracks (`repeat(cols, ${cellSize}px)` + matching
   `gridAutoRows`) so every track is exactly cellSize wide AND tall. By
   construction `cols × cellSize + (cols-1) × gap == measured width`,
   so the row fills edge-to-edge with cells that are guaranteed square.

PhotoThumbnail gains an opt-in `fill` prop the timeline uses to switch
its inline width/height to 100%, so the cell stretches to whatever the
parent grid track gives it. Heap sidebar / non-grid callers still get
explicit `size`-by-`size` square thumbnails as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:19:29 +02:00
root
ce25a4460e fix: never cache index.html so new bundle hashes are picked up
Vite content-hashes the JS/CSS bundle filenames, but the only thing
that tells the browser to fetch a new hash is a fresh index.html.
Without explicit no-cache headers nginx falls back to heuristic
caching, so users keep loading the old index.html → old bundle hash
until they hard-reload. Just hit this rolling out the timeline
pagination fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:42:16 +02:00
root
872be4e0cf fix: load full library + stretch grid to fill row width
Two unrelated bugs surfaced together because the symptom looked
similar ("missing photos in the grid"):

usePhotosQuery only ever fetched page 1 with per_page=500, so any
filter matching more than 500 photos silently truncated. With a
13k-photo library that meant the Timeline only showed ~3.8% of
matches and folders with many descendants looked broken. Walks all
pages now (capped at 200 = 100k photos as a sanity bound), keying
the React Query cache on the full filter set as before.

Timeline grid was leaving an unused horizontal strip on the right.
Two issues in the column math:
  - off-by-one: floor((W - 2P) / (T + G)) double-counts gaps. With N
    columns there are only N-1 inter-cell gaps, so the correct form
    is floor((W - 2P + G) / (T + G)). Reclaims a column whenever the
    remainder almost fits.
  - the floor remainder was discarded instead of distributed back
    into the cells. Treat THUMBNAIL_SIZE as a minimum and stretch
    each cell to (available - (cols-1)*gap) / cols so the row fills
    the container.

Also swap the resize listener for a ResizeObserver on the scroll
container so the grid re-flows when the sidebar collapses (window
resize alone misses that).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:22:06 +02:00
root
d27ec1af2e fix: prune orphaned folder rows alongside photo rows
prune_missing_photos() previously only deleted Photo rows whose files
were gone, leaving every folder row from the old library in the DB —
which made the sidebar tree wildly out of sync with the on-disk
structure (still showing /photos/2024/, /photos/2026/03/, etc. that
no longer exist).

Now also drops Folder rows whose path doesn't resolve under a mounted
source root, with the same defensive "skip if source root unmounted"
guard. The Settings orphan card surfaces both counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:50:32 +02:00
root
697343646a feat: prune orphaned photo rows + retry-pending action
Adds /api/v1/library/maintenance/{missing-stats,prune-missing} backed
by a new cleanup helper that deletes Photo rows whose files no longer
exist on disk under a *mounted* source root. Skips photos under
unmounted roots so a temporarily-disconnected drive doesn't get
silently nuked.

Settings panel surfaces the orphan count with a destructive Prune
button, plus a "Kick pending" action that re-queues photos stuck in
processing_status='pending' (typically left behind when the scanner
created the row but the worker never picked up the thumbnail task).

Common trigger: PHOTO_DIRS in .env was repointed at a different
library root, leaving every old row dangling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:47:06 +02:00
root
3df8add3b6 fix: stop scan crashing on duplicate file hashes
scan.py used scalar_one_or_none() to test whether any other photo
shared the same file_hash, but that helper raises MultipleResultsFound
the moment 2+ rows match — i.e. exactly the duplicate case it was
trying to flag. Every file beyond the second copy bombed out with
"Multiple rows were found when one or none was required" and was
left in the failed bucket. Replace with a COUNT(*) > 0 check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:14:40 +02:00
root
42250aa16e feat: worker diagnostics in settings panel
Adds /api/v1/library/maintenance/worker-status (Celery inspect + queue
depths + recent failed photos) and a Workers section in the Settings
dialog so users can debug stuck queues and task failures without
tailing container logs. Auto-polls every 5s while open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:11:33 +02:00
9 changed files with 874 additions and 45 deletions

View File

@@ -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

View File

@@ -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."""

View File

@@ -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'
)

View File

@@ -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";

View File

@@ -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,

View File

@@ -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

View File

@@ -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}

View File

@@ -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,
})

View File

@@ -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 }> => {