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 { resizable } from '$lib/actions/resizable';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { preview } from '$lib/stores/preview.svelte';
|
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 LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.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
|
// PreviewOverlay is the full-screen lightbox — keyboard nav, map
|
||||||
// pane, exif sidebar. Users who never click into a photo never need
|
// 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
|
// 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
|
region inside each page scrolls. The mule header, toolbar, and
|
||||||
sidebars stay fixed regardless of how far you scroll the grid. -->
|
sidebars stay fixed regardless of how far you scroll the grid. -->
|
||||||
<div class="flex h-screen flex-col overflow-hidden">
|
<div class="flex h-screen flex-col overflow-hidden">
|
||||||
<AnimatedMule />
|
<AnimatedMule>
|
||||||
|
<IndexerStatusPill />
|
||||||
|
</AnimatedMule>
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
{#if !view.leftSidebarCollapsed}
|
{#if !view.leftSidebarCollapsed}
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
Reference in New Issue
Block a user