feat(web): header pill showing PhotoPrism indexer status
Subscribes to PhotoPrism's /api/v1/ws channel on login and surfaces index.indexing / index.updating / index.completed events as a small status pill in the header (next to the AnimatedMule wordmark). - Shows "Indexing" + the current filename (basename, monospace) during the scan pass, "Finalizing — <step>" during faces/counts/folders/ purge/moments, and "Indexed in Ns" for ~4s after completion before fading. - Per-file events arrive many per second on large libraries — throttled to 150 ms with a trailing-edge update so the pill stays calm and always lands on the most recent filename. Step and completion events bypass the throttle. - Filename slot is fixed at 24ch so the pill width stays constant through a run (no horizontal jitter as filenames change length); the full path is exposed via the parent's `title` for hover. - WS reconnect uses exponential backoff capped at 30 s, and the store tears down cleanly on logout so we don't leak sockets across identities. Defensive parsing throughout: PhotoPrism's WS protocol isn't a stable contract, so unknown event shapes are ignored rather than thrown — worst-case the pill stays idle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
49
web/src/lib/components/layout/IndexerStatusPill.svelte
Normal file
49
web/src/lib/components/layout/IndexerStatusPill.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<!--
|
||||
Compact status pill that appears in the header while PhotoPrism's
|
||||
indexer is doing work. Driven by the indexer store, which subscribes
|
||||
to PhotoPrism's WS channel. Renders nothing when idle so it never
|
||||
steals header real estate from the user.
|
||||
|
||||
The `detail` (current path/file) is exposed via `title` rather than
|
||||
rendered inline — the pill stays narrow even on slow flashes through
|
||||
a deep library, and hover surfaces the detail for users who care.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { indexer } from '$lib/stores/indexer.svelte';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
// PhotoPrism's `fileName` arrives as the full relative path
|
||||
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
|
||||
// recognition; the full path stays in the `title` for users who hover.
|
||||
const basename = $derived.by(() => {
|
||||
const d = indexer.detail;
|
||||
if (!d) return '';
|
||||
const i = d.lastIndexOf('/');
|
||||
return i >= 0 ? d.slice(i + 1) : d;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if indexer.active || indexer.label}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
|
||||
title={indexer.detail ?? indexer.label}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if indexer.active}
|
||||
<Loader2 class="h-3 w-3 animate-spin text-primary" />
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{indexer.label}</span>
|
||||
{#if basename}
|
||||
<!-- Fixed-width slot so the pill stops shrinking/growing as
|
||||
PhotoPrism rattles through files of different name lengths.
|
||||
`w-[24ch]` locks the column; `truncate` ellipsises anything
|
||||
longer. The full path remains in the parent's `title`. -->
|
||||
<span
|
||||
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{basename}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
251
web/src/lib/stores/indexer.svelte.ts
Normal file
251
web/src/lib/stores/indexer.svelte.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { isAuthenticated, session } from './session.svelte';
|
||||
|
||||
/**
|
||||
* Indexer status, populated from PhotoPrism's WebSocket channel
|
||||
* (`/api/v1/ws`). Surfaces in the header pill so the user can see
|
||||
* when the library is being scanned — whether triggered by a sidecar
|
||||
* folder mutation, a manual reindex from PhotoPrism's own UI, or a
|
||||
* scheduled cron.
|
||||
*
|
||||
* Tolerant by design: PhotoPrism's WS protocol isn't part of any
|
||||
* stable contract, so unknown event shapes are ignored rather than
|
||||
* thrown. The fields here are derived from the bundled PhotoPrism
|
||||
* client's event names (`index`, `indexing`, `library_index`); if
|
||||
* the protocol ever changes shape, the pill simply stays idle
|
||||
* instead of breaking the app.
|
||||
*/
|
||||
interface IndexerState {
|
||||
active: boolean;
|
||||
/** Human-readable status, e.g. "Indexing", "Index complete". */
|
||||
label: string;
|
||||
/** Optional sub-text — current file or folder being processed. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export const indexer = $state<IndexerState>({
|
||||
active: false,
|
||||
label: ''
|
||||
});
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backoffMs = 1000;
|
||||
let clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// PhotoPrism fires `index.indexing` once per file — that's many events
|
||||
// per second on a large library. Throttle UI updates from that stream
|
||||
// to ~150 ms so the pill text doesn't flicker and Svelte isn't re-running
|
||||
// the render scope on every other frame. Step / completion events bypass
|
||||
// the throttle (they're already infrequent and the user benefits from
|
||||
// seeing them land immediately).
|
||||
const FILE_UPDATE_THROTTLE_MS = 150;
|
||||
let lastFileUpdateAt = 0;
|
||||
let pendingFileTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingFileName: string | undefined;
|
||||
|
||||
function url(): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${location.host}/api/v1/ws`;
|
||||
}
|
||||
|
||||
function setActive(label: string, detail?: string): void {
|
||||
if (clearTimer !== null) {
|
||||
clearTimeout(clearTimer);
|
||||
clearTimer = null;
|
||||
}
|
||||
indexer.active = true;
|
||||
indexer.label = label;
|
||||
indexer.detail = detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled variant of `setActive` for the per-file `index.indexing`
|
||||
* stream. Leading-edge fires immediately so the user sees activity start
|
||||
* without delay; subsequent events within FILE_UPDATE_THROTTLE_MS are
|
||||
* coalesced into a single trailing-edge update so the displayed filename
|
||||
* lands on the *most recent* file rather than some arbitrary one in the
|
||||
* middle of the burst.
|
||||
*/
|
||||
function setActiveThrottled(label: string, fileName: string | undefined): void {
|
||||
pendingFileName = fileName;
|
||||
const now = Date.now();
|
||||
const since = now - lastFileUpdateAt;
|
||||
if (since >= FILE_UPDATE_THROTTLE_MS) {
|
||||
lastFileUpdateAt = now;
|
||||
setActive(label, pendingFileName);
|
||||
pendingFileName = undefined;
|
||||
return;
|
||||
}
|
||||
if (pendingFileTimer !== null) return;
|
||||
pendingFileTimer = setTimeout(() => {
|
||||
pendingFileTimer = null;
|
||||
lastFileUpdateAt = Date.now();
|
||||
setActive(label, pendingFileName);
|
||||
pendingFileName = undefined;
|
||||
}, FILE_UPDATE_THROTTLE_MS - since);
|
||||
}
|
||||
|
||||
function setCompleted(label = 'Index complete'): void {
|
||||
// Drop any throttled `index.indexing` update still in flight — once
|
||||
// PhotoPrism says "done" we don't want a stale filename to land on
|
||||
// top of the completion label a few ms later.
|
||||
if (pendingFileTimer !== null) {
|
||||
clearTimeout(pendingFileTimer);
|
||||
pendingFileTimer = null;
|
||||
pendingFileName = undefined;
|
||||
}
|
||||
indexer.active = false;
|
||||
indexer.label = label;
|
||||
indexer.detail = undefined;
|
||||
// Hold the "complete" label for a few seconds before clearing so the
|
||||
// user gets a chance to see it; the pill collapses to nothing once
|
||||
// label goes empty.
|
||||
if (clearTimer !== null) clearTimeout(clearTimer);
|
||||
clearTimer = setTimeout(() => {
|
||||
if (!indexer.active) {
|
||||
indexer.label = '';
|
||||
indexer.detail = undefined;
|
||||
}
|
||||
clearTimer = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function handleMessage(raw: string): void {
|
||||
let msg: unknown;
|
||||
try {
|
||||
msg = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
// PhotoPrism wraps events in `{event, data}`. Some versions nest the
|
||||
// payload under `{msg: {event, data}}`; tolerate both.
|
||||
const top = msg as Record<string, unknown>;
|
||||
const inner = (top.msg as Record<string, unknown> | undefined) ?? top;
|
||||
const eventName = inner.event as string | undefined;
|
||||
const data = (inner.data ?? {}) as Record<string, unknown>;
|
||||
if (!eventName) return;
|
||||
// PhotoPrism's WS protocol isn't a stable contract; log the live shape
|
||||
// at `debug` (hidden by default in DevTools — toggle "Verbose" to see)
|
||||
// so future-us can spot new indexer event names without instrumenting
|
||||
// the entire app.
|
||||
console.debug('[indexer]', eventName, data);
|
||||
switch (eventName) {
|
||||
case 'index.indexing': {
|
||||
// Per-file event during the scan pass. PhotoPrism emits one
|
||||
// of these per file it touches, so this fires often — route
|
||||
// it through the throttled setter to keep the pill calm.
|
||||
const fileName =
|
||||
(data.fileName as string | undefined) ?? (data.baseName as string | undefined);
|
||||
setActiveThrottled('Indexing', fileName);
|
||||
return;
|
||||
}
|
||||
case 'index.updating': {
|
||||
// Post-file passes (faces, counts, folders, purge, moments)
|
||||
// run after the file scan finishes. They're cheap individually
|
||||
// but the user sees them so showing the step name keeps the
|
||||
// pill informative instead of looking stuck on the last file.
|
||||
const step = String(data.step ?? '');
|
||||
setActive(step ? `Finalizing — ${step}` : 'Finalizing', undefined);
|
||||
return;
|
||||
}
|
||||
case 'index.completed': {
|
||||
const seconds = typeof data.seconds === 'number' ? data.seconds : undefined;
|
||||
setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete');
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// Other events (notify.*, log.*, photos.updated, count.*,
|
||||
// config.updated) are out of scope for the indexer pill.
|
||||
// Intentionally ignored.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (!browser) return;
|
||||
if (!isAuthenticated()) return;
|
||||
if (ws) return;
|
||||
let sock: WebSocket;
|
||||
try {
|
||||
sock = new WebSocket(url());
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
sock.addEventListener('open', () => {
|
||||
backoffMs = 1000;
|
||||
// PhotoPrism authenticates the WS channel by reading the
|
||||
// `auth_token` cookie set during login, but if the SPA is signed
|
||||
// in via the legacy access-token store (no cookie), send an
|
||||
// inline auth probe so the server can scope events to this
|
||||
// session. Best-effort: failures don't block the listen path.
|
||||
try {
|
||||
sock.send(
|
||||
JSON.stringify({ session: session.id, token: session.accessToken })
|
||||
);
|
||||
} catch {
|
||||
/* best-effort; ignore */
|
||||
}
|
||||
});
|
||||
sock.addEventListener('message', (e) => {
|
||||
if (typeof e.data === 'string') handleMessage(e.data);
|
||||
});
|
||||
sock.addEventListener('close', () => {
|
||||
ws = null;
|
||||
if (!isAuthenticated()) return;
|
||||
scheduleReconnect();
|
||||
});
|
||||
sock.addEventListener('error', () => {
|
||||
// Let `close` drive the reconnect path; logging on every transient
|
||||
// error spams the console without adding signal.
|
||||
});
|
||||
ws = sock;
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer !== null) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, backoffMs);
|
||||
backoffMs = Math.min(backoffMs * 2, 30_000);
|
||||
}
|
||||
|
||||
/** Start listening to PhotoPrism's WS for indexer events. Idempotent —
|
||||
* safe to call multiple times; only one socket is held at a time. */
|
||||
export function startIndexerWatch(): void {
|
||||
if (!browser) return;
|
||||
connect();
|
||||
}
|
||||
|
||||
/** Tear down the WS and clear any displayed status. Call on logout. */
|
||||
export function stopIndexerWatch(): void {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (clearTimer !== null) {
|
||||
clearTimeout(clearTimer);
|
||||
clearTimer = null;
|
||||
}
|
||||
if (pendingFileTimer !== null) {
|
||||
clearTimeout(pendingFileTimer);
|
||||
pendingFileTimer = null;
|
||||
pendingFileName = undefined;
|
||||
}
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close(1000);
|
||||
} catch {
|
||||
/* close errors are informational only */
|
||||
}
|
||||
ws = null;
|
||||
}
|
||||
indexer.active = false;
|
||||
indexer.label = '';
|
||||
indexer.detail = undefined;
|
||||
backoffMs = 1000;
|
||||
lastFileUpdateAt = 0;
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { preview } from '$lib/stores/preview.svelte';
|
||||
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
||||
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
||||
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||
|
||||
@@ -44,6 +46,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Indexer status feed. The store opens a WS to PhotoPrism's /api/v1/ws
|
||||
// channel once we're signed in and tears it down on logout. Kept here
|
||||
// (not in the store's top-level) so SvelteKit's SSR pass never touches
|
||||
// `WebSocket` and so logout can short-circuit reconnect attempts.
|
||||
$effect(() => {
|
||||
if (!browser || !bootstrapped) return;
|
||||
if (isAuthenticated()) startIndexerWatch();
|
||||
else stopIndexerWatch();
|
||||
});
|
||||
|
||||
// PreviewOverlay is the full-screen lightbox — keyboard nav, map
|
||||
// pane, exif sidebar. Users who never click into a photo never need
|
||||
// it, so we lazy-import the first time `preview.uid` flips non-null
|
||||
@@ -75,7 +87,9 @@
|
||||
region inside each page scrolls. The mule header, toolbar, and
|
||||
sidebars stay fixed regardless of how far you scroll the grid. -->
|
||||
<div class="flex h-screen flex-col overflow-hidden">
|
||||
<AnimatedMule />
|
||||
<AnimatedMule>
|
||||
<IndexerStatusPill />
|
||||
</AnimatedMule>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
{#if !view.leftSidebarCollapsed}
|
||||
<aside
|
||||
|
||||
Reference in New Issue
Block a user