1import RotateCw from "lucide-solid/icons/rotate-cw";
2import { createSignal, For, Index, onCleanup, type Resource, Show } from "solid-js";
3import { A } from "@solidjs/router";
4import { parseResponse } from "hono/client";
5import type { PaperCloverStats, ProgressNode } from "../types/paperClover.ts";
6import { api, queries, unconnected } from "../api.ts";
7import { Ago } from "../components/Ago.tsx";
8import { showTextDialog } from "../components/Dialog.tsx";
9import { lastGood, Loaded } from "../components/Loaded.tsx";
10import { type StatusKind, StatusLabel } from "../components/Status.tsx";
11import { TabBar } from "../components/TabBar.tsx";
12import { toast } from "../components/Toast.tsx";
13import { bytes, clock, count, datetime, duration, percent, plural, until } from "../format.ts";
14import "./PaperClover.css";
15
16const SITE = "https://paperclover.net/file";
17/** The runtime side of the source of truth: its logs, restarts and resources. */
18const SERVICE = "/services/clover-source-of-truth";
19/** Lines a progress node lists before folding the rest into "[N more]". */
20const SHOWN = 6;
21
22const scan = (path: string | null) => parseResponse(api["paper-clover"].scan.$post({ json: { path } }));
23const url = (base: string, path: string) => base + path.split("/").map(encodeURIComponent).join("/");
24
25/**
26 * The name opens the file on paperclover.net; the folder opens in copyparty when `browse`, its page for the Published
27 * folder, is known.
28 */
29function FileName(props: { path: string; browse: string | null; tip?: string }) {
30 const cut = () => props.path.lastIndexOf("/");
31 const folder = () => props.path.slice(0, cut()) || "/";
32 return (
33 <div class="file-name">
34 <a href={url(SITE, props.path)} target="_blank" rel="noreferrer" data-tip={props.tip}>{props.path.slice(cut() + 1)}</a>
35 <Show when={props.browse} fallback={<span class="folder">{folder()}</span>}>
36 {(browse) => <a class="folder" href={url(browse(), folder()) + "/"} target="_blank" rel="noreferrer">{folder()}</a>}
37 </Show>
38 </div>
39 );
40}
41
42function health(s: PaperCloverStats, failing: number, blocked: number): [StatusKind, string] {
43 if (!s.indexer.enabled) return ["stopped", "indexer off"];
44 if (s.lastSweep?.error) return ["down", "full scan failed"];
45 if (failing) return ["degraded", `${plural(failing, "file")} failing`];
46 if (blocked) return ["degraded", `${plural(blocked, "processor")} can't run`];
47 if (!s.indexer.watching) return ["degraded", "not watching for changes"];
48 if (s.lastSweep && !s.lastSweep.endedAt) return ["working", "scanning everything"];
49 return ["healthy", "healthy"];
50}
51
52function Issues(props: {
53 stats: PaperCloverStats;
54 browse: string | null;
55 blocked: PaperCloverStats["processors"];
56 busy: (path: string) => boolean;
57 rescan: (path: string | null) => Promise<void>;
58}) {
59 const title = (name: string) => props.stats.processors.find((p) => p.name === name)?.title ?? name;
60 const next = () => props.stats.indexer.nextSweepAt;
61 const [retryingAll, setRetryingAll] = createSignal(false);
62 const retryAll = async () => {
63 setRetryingAll(true);
64 try {
65 await Promise.all(props.stats.failures.map((file) => props.rescan(file.path)));
66 } finally {
67 setRetryingAll(false);
68 }
69 };
70
71 return (
72 <div class="card issues">
73 <div class="card-title">
74 needs attention
75 <Show when={props.stats.failures.length && next()}>
76 {(t) => <span class="sub">failed files retry on their own {until(t())}</span>}
77 </Show>
78 <span class="spacer" />
79 <Show when={props.stats.failures.length > 1}>
80 <button class="button small" disabled={retryingAll()} aria-busy={retryingAll()} onClick={retryAll}>
81 retry all
82 </button>
83 </Show>
84 </div>
85 <ul>
86 <Show when={props.stats.lastSweep?.error}>
87 {(error) => (
88 <li>
89 <div class="what">full scan</div>
90 <div class="why">{error().replace(/^Error: /, "")}</div>
91 <Ago t={props.stats.lastSweep!.startedAt} />
92 <RetryButton busy={!props.stats.lastSweep!.endedAt} run={() => props.rescan(null)} />
93 </li>
94 )}
95 </Show>
96 <Show when={props.stats.indexer.enabled && !props.stats.indexer.watching}>
97 <li>
98 <div class="what">not watching for changes</div>
99 <div class="why">New files wait for the next full scan{next() ? `, ${until(next()!)}` : ""}.</div>
100 </li>
101 </Show>
102 <Index each={props.blocked}>
103 {(p) => (
104 <li>
105 <div class="what">{p().title}</div>
106 <div class="why" data-tip={`Its tool is missing, or ${p().name} is in CLOVER_PROCESSORS_DISABLE`}>
107 Can't run on this machine. {plural(p().applicable - p().done, "file")} waiting.
108 </div>
109 </li>
110 )}
111 </Index>
112 <For each={props.stats.failures.map((file) => file.path)}>
113 {(path) => {
114 const [open, setOpen] = createSignal(false);
115 const file = () => props.stats.failures.find((f) => f.path === path);
116 const failures = () => file()?.errors ?? [];
117 return (
118 <li>
119 <FileName path={path} browse={props.browse} tip={file() && `${bytes(file()!.size)}, ${file()!.mime}`} />
120 <button class="why" aria-expanded={open()} data-tip={open() ? "hide details" : "show details"}
121 onClick={() => setOpen(!open())}>
122 <Index each={failures()}>
123 {(f) => (
124 <span>
125 <span class="muted">{title(f().processor)}:</span>{" "}
126 {f().error.split("\n")[0]!.replace(/^Error: /, "")}
127 </span>
128 )}
129 </Index>
130 </button>
131 <Ago t={Math.max(...failures().map((f) => f.at))} />
132 <RetryButton busy={props.busy(path)} run={() => props.rescan(path)} />
133 <Show when={open()}>
134 <div class="detail">
135 <p><span class="muted">made</span> {file()?.made.map(title).join(", ") || "nothing yet"}</p>
136 <pre class="trace">{failures().map((f) => `${title(f.processor)}: ${f.error}`).join("\n\n")}</pre>
137 </div>
138 </Show>
139 </li>
140 );
141 }}
142 </For>
143 </ul>
144 </div>
145 );
146}
147
148/** Spins while the request is out, then for as long as `busy` says the work is still going. */
149function RetryButton(props: { busy: boolean; run: () => Promise<void> }) {
150 const [sending, setSending] = createSignal(false);
151 const click = async () => {
152 setSending(true);
153 await props.run().finally(() => setSending(false));
154 };
155 return (
156 <button class="button small" disabled={sending() || props.busy} aria-busy={sending() || props.busy} onClick={click}>
157 retry
158 </button>
159 );
160}
161
162/** A port of @clo/lib/progress's terminal renderer (`formatAnsi`): guides, bars, dim counts and "[N more]". */
163function Tree(props: { nodes: ProgressNode[]; browse: string | null; top?: boolean }) {
164 const [all, setAll] = createSignal(false);
165 const folded = () => (all() || props.nodes.length <= SHOWN + 1 ? 0 : props.nodes.length - SHOWN);
166 return (
167 <ul>
168 <Index each={folded() ? props.nodes.slice(0, SHOWN) : props.nodes}>
169 {(node) => {
170 const text = () => /^(.*?)( \(.*\))?$/.exec(node().text)!;
171 const age = () => Date.now() / 1000 - node().since;
172 const cut = () => node().path!.lastIndexOf("/");
173 return (
174 <li>
175 <div class="line">
176 <Show when={props.top}><span class="spinner" aria-hidden="true" /></Show>
177 <Show when={node().progress !== null}>
178 <span class="bar" role="progressbar" aria-valuenow={Math.round(node().progress! * 100)} tabindex="0"
179 data-tip={[node().count, node().eta && `done at ${clock(node().eta!)}`].filter(Boolean).join(", ") || undefined}>
180 <span style={{ width: `${node().progress! * 100}%` }} />
181 </span>
182 <span class="dim">
183 [{percent(node().progress! * 100)}{node().eta ? `, ${duration(node().eta! - Date.now() / 1000)}` : ""}]
184 </span>
185 </Show>
186 <Show when={node().progress === null && node().count}>
187 <span class="dim">[{node().count}]</span>
188 </Show>
189 <Show when={node().path}>
190 {(path) => (
191 <span class="path">
192 <Show when={props.browse} fallback={<span class="dim">{path().slice(1, cut() + 1)}</span>}>
193 {(browse) => (
194 <a class="dim" href={url(browse(), path().slice(0, cut())) + "/"} target="_blank" rel="noreferrer">
195 {path().slice(1, cut() + 1)}
196 </a>
197 )}
198 </Show>
199 <a href={url(SITE, path())} target="_blank" rel="noreferrer">{path().slice(cut() + 1)}</a>
200 </span>
201 )}
202 </Show>
203 <span class={node().path ? "step" : "text"}>{text()[1]}</span>
204 <Show when={text()[2]}><span class="dim">{text()[2]}</span></Show>
205 <Show when={!props.top && node().progress === null && age() >= 60}>
206 <span class="dim" tabindex="0" data-tip={`since ${datetime(node().since)}`}>{duration(age())}</span>
207 </Show>
208 </div>
209 <Show when={node().children.length}><Tree nodes={node().children} browse={props.browse} /></Show>
210 <Index each={node().logs.slice(-3)}>
211 {(log) => (
212 <div class="line log">
213 <span class="dim">&gt;</span>
214 <Show when={log().level !== "info"}><span class={log().level}>{log().level}:</span></Show>
215 <span>{log().text}</span>
216 </div>
217 )}
218 </Index>
219 </li>
220 );
221 }}
222 </Index>
223 <Show when={folded()}>
224 <li><button class="line more" onClick={() => setAll(true)}>[{folded()} more]</button></li>
225 </Show>
226 </ul>
227 );
228}
229
230function Running(props: { activity: Resource<ProgressNode[]>; browse: string | null; retry: () => void }) {
231 return (
232 <div class="card running">
233 <Loaded data={props.activity} what="what's running" retry={props.retry}
234 skeleton={<div class="skeleton" style={{ height: "66px" }} />}>
235 {(nodes) => (
236 <Show when={nodes().length}
237 fallback={<div class="empty">Nothing running. Files show up here while they're indexed or processed.</div>}>
238 <Tree nodes={nodes()} browse={props.browse} top />
239 </Show>
240 )}
241 </Loaded>
242 </div>
243 );
244}
245
246/** A processor's family is its name up to the first dash: "av1" for "av1-au". */
247const family = (name: string) => name.split("-")[0]!;
248
249/**
250 * Series colors for the families with output, hashed from the family name so they stay put as processors come and
251 * go; a family whose slot is taken probes to the next free one, in name order.
252 */
253function familyColors(processors: PaperCloverStats["processors"]) {
254 const slots = new Map<string, number>();
255 for (const name of [...new Set(processors.filter((p) => p.bytes).map((p) => family(p.name)))].sort()) {
256 let slot = [...name].reduce((hash, c) => Math.imul(hash ^ c.charCodeAt(0), 16777619), 2166136261) >>> 29;
257 while (slots.size < 8 && [...slots.values()].includes(slot)) slot = (slot + 1) % 8;
258 slots.set(name, slot);
259 }
260 return (processor: string) => {
261 const slot = slots.get(family(processor));
262 return slot === undefined ? undefined : `var(--series-${slot + 1})`;
263 };
264}
265
266/** Published by type and derived by processor on one scale, then published by top-level folder. */
267function Archive(props: { stats: PaperCloverStats; browse: string | null }) {
268 const scale = () => Math.max(props.stats.bytes, props.stats.derived.bytes);
269 const types = () => {
270 const sorted = props.stats.types.toSorted((a, b) => b.bytes - a.bytes);
271 const rest = sorted.slice(6);
272 const other = props.stats.bytes - sorted.slice(0, 6).reduce((sum, type) => sum + type.bytes, 0);
273 return [
274 ...sorted.slice(0, 6).map((type, i) => ({
275 label: type.ext || "no extension",
276 bytes: type.bytes,
277 tip: `${bytes(type.bytes)} in ${plural(type.files, "file")} (${percent((type.bytes / props.stats.bytes) * 100)})`,
278 color: `var(--series-${i + 1})`,
279 })),
280 ...(other > 0
281 ? [{
282 label: "other",
283 bytes: other,
284 tip: `${bytes(other)}: ` + rest.slice(0, 4).map((type) => `${type.ext} ${bytes(type.bytes)}`).join(", ")
285 + (rest.length > 4 ? `, ${rest.length - 4} more` : ""),
286 color: "var(--other)",
287 }]
288 : []),
289 ];
290 };
291 const derived = () => {
292 const color = familyColors(props.stats.processors);
293 const made = props.stats.processors.flatMap((p) => p.bytes !== null && p.outputs !== null && p.bytes > 0
294 ? [{ ...p, bytes: p.bytes, outputs: p.outputs }] : []);
295 const total = (name: string) => made.reduce((sum, p) => sum + (family(p.name) === name ? p.bytes : 0), 0);
296 return made.toSorted((a, b) => total(family(b.name)) - total(family(a.name)) || b.bytes - a.bytes).map((p) => ({
297 label: p.title.replace(/^encode /, ""),
298 bytes: p.bytes,
299 tip: `${p.title}: ${bytes(p.bytes)} in ${plural(p.outputs, "file")} (${percent((p.bytes / props.stats.derived.bytes) * 100)})`,
300 color: color(p.name)!,
301 }));
302 };
303 const folders = () => props.stats.folders.toSorted((a, b) => a.name.localeCompare(b.name));
304 const tallest = () => Math.max(...props.stats.folders.map((folder) => folder.bytes));
305
306 const Row = (row: { label: string; total: number; href?: string | null; tip: string; segments: ReturnType<typeof types> }) => (
307 <>
308 <a class="total" href={row.href ?? undefined} tabindex={row.href ? undefined : 0} target="_blank" rel="noreferrer"
309 data-tip={row.tip}>
310 <b>{bytes(row.total)}</b> {row.label}
311 </a>
312 <div class="segments" style={{ width: `${(row.total / scale()) * 100}%` }}>
313 <Index each={row.segments}>
314 {(segment) => (
315 <div class="segment" tabindex="0" data-tip={segment().tip} style={{ "flex-grow": segment().bytes }}>
316 <span class="label"><span>{segment().label}</span><span class="muted">{bytes(segment().bytes)}</span></span>
317 <span class="mark" style={{ background: segment().color }} />
318 </div>
319 )}
320 </Index>
321 </div>
322 </>
323 );
324
325 return (
326 <div class="card archive">
327 <div class="space">
328 <Row label="published" total={props.stats.bytes} href={props.browse} segments={types()}
329 tip={`${plural(props.stats.files, "file")} in ${plural(props.stats.directories, "folder")}`} />
330 <Row label="derived" total={props.stats.derived.bytes} segments={derived()}
331 tip={`${plural(props.stats.derived.files, "file")}, ${(props.stats.derived.bytes / props.stats.bytes).toFixed(1)}× published`} />
332 </div>
333 <div class="folders">
334 <Index each={folders()}>
335 {(folder) => (
336 <a class="folder" href={props.browse ? url(props.browse, `/${folder().name}`) + "/" : undefined} target="_blank"
337 rel="noreferrer" data-tip={plural(folder().files, "file")}>
338 <span class="column"><span style={{ height: `${(folder().bytes / tallest()) * 100}%` }} /></span>
339 <span class="name">{folder().name}</span>
340 <span class="muted">{bytes(folder().bytes)}</span>
341 </a>
342 )}
343 </Index>
344 </div>
345 </div>
346 );
347}
348
349function Processors(props: { stats: PaperCloverStats }) {
350 const left = () => props.stats.processors.reduce((sum, p) => sum + p.applicable - p.done, 0);
351 const work = () => {
352 let total = 0;
353 for (const p of props.stats.processors) {
354 if (p.seconds === null) return null;
355 total += (p.applicable - p.done) * p.seconds;
356 }
357 return total;
358 };
359 const color = () => familyColors(props.stats.processors);
360 return (
361 <div class="card processors">
362 <div class="card-title">
363 processors
364 <span class="sub" tabindex={work() !== null && left() ? 0 : undefined} data-tip={work() !== null && left() ? `about ${duration(work() ?? 0)} of work` : undefined}>
365 {left() ? `${plural(left(), "job")} left` : "all caught up"}
366 </span>
367 </div>
368 <table class="data">
369 <thead><tr><th /><th class="num">per file</th><th class="num">output</th><th /></tr></thead>
370 <tbody>
371 <Index each={props.stats.processors}>
372 {(p) => (
373 <tr tabindex="0" data-tip={[`${count(p().done)} / ${plural(p().applicable, "file")} done`,
374 p().failed && `${count(p().failed)} failed`, p().stale && `${count(p().stale)} from an older version`,
375 `version ${p().version}`].filter(Boolean).join(", ")}>
376 <td><span class="swatch" style={{ background: color()(p().name) }} />{p().title}</td>
377 <td class="num muted">{p().seconds === null ? "" : (p().seconds ?? 0) < 10 ? `${(p().seconds ?? 0).toFixed(1)}s` : duration(p().seconds ?? 0)}</td>
378 <td class="num">{p().bytes !== null && (p().bytes ?? 0) > 0 ? bytes(p().bytes ?? 0) : ""}</td>
379 <td class="num">
380 <Show when={p().runnable} fallback={<StatusLabel health="degraded">can't run</StatusLabel>}>
381 <Show when={p().failed} fallback={<span class="muted">{p().done < p().applicable ? `${count(p().applicable - p().done)} left` : ""}</span>}>
382 <span class="failed">{count(p().failed)} failed</span>
383 </Show>
384 </Show>
385 </td>
386 </tr>
387 )}
388 </Index>
389 </tbody>
390 </table>
391 </div>
392 );
393}
394
395function Files(props: { stats: PaperCloverStats; browse: string | null }) {
396 const [largest, setLargest] = createSignal(false);
397 const files = (): { path: string; size: number; at?: number; duration?: number; dimensions?: string }[] =>
398 largest() ? props.stats.largest : props.stats.recent;
399 return (
400 <div class="card">
401 <div class="card-title">
402 <TabBar label="files">
403 <button aria-pressed={!largest()} onClick={() => setLargest(false)}>recent</button>
404 <button aria-pressed={largest()} onClick={() => setLargest(true)}>largest</button>
405 </TabBar>
406 </div>
407 <Show when={files().length} fallback={<div class="empty">No files indexed yet.</div>}>
408 <ul class="files">
409 <Index each={files()}>
410 {(file) => (
411 <li>
412 <FileName path={file().path} browse={props.browse} tip={file().dimensions?.replace("x", "×")} />
413 <div class="num">
414 <div>{bytes(file().size)}</div>
415 <div class="muted">
416 {file().at !== undefined ? <Ago t={file().at!} /> : file().duration ? duration(file().duration!) : ""}
417 </div>
418 </div>
419 </li>
420 )}
421 </Index>
422 </ul>
423 </Show>
424 </div>
425 );
426}
427
428export function PaperClover() {
429 const [stats, { refetch }] = queries.paperClover.use();
430 const [activity, activityControl] = queries.paperCloverActivity.use();
431 const timers = [
432 setInterval(() => unconnected(stats.error) || refetch(), 5000),
433 setInterval(() => unconnected(activity.error) || activityControl.refetch(), 2000),
434 ];
435 onCleanup(() => timers.forEach(clearInterval));
436
437 const current = lastGood(stats);
438 const now = lastGood(activity);
439 const blocked = () => current()?.processors.filter((p) => !p.runnable && p.done < p.applicable) ?? [];
440 const busy = (target: string) => {
441 const covers = (node: ProgressNode): boolean =>
442 Boolean(node.path && (target === node.path || target.startsWith(node.path + "/"))) || node.children.some(covers);
443 return now()?.some(covers) ?? false;
444 };
445
446 const rescan = async (target: string | null) => {
447 await scan(target);
448 activityControl.refetch();
449 refetch();
450 };
451
452 const rescanDialog = () => showTextDialog({
453 title: "Rescan",
454 description: "Leave it empty to rescan everything.",
455 label: "folder or file",
456 placeholder: "/2026/friends…",
457 validateInput: () => true,
458 confirmLabel: "rescan",
459 onConfirm: async (value) => {
460 const path = value.trim().replace(/^\/+|\/+$/g, "");
461 await rescan(path ? `/${path}` : null);
462 toast(path ? `Rescanning /${path}` : "Rescanning everything");
463 },
464 });
465
466 const off = () => current()?.indexer.enabled === false;
467
468 return (
469 <div class="page paper-clover">
470 <div class="page-head">
471 <h1><A href={SERVICE} data-tip="logs and runtime">paper clover</A></h1>
472 <div class="stats">
473 <Show when={current()}
474 fallback={stats.state !== "errored" && <For each={[120, 132]}>{(width) => <span class="skeleton stat-skeleton" style={{ width: `${width}px` }} />}</For>}>
475 {(s) => {
476 const state = () => health(s(), s().failures.length, blocked().length);
477 const sweep = () => s().lastSweep;
478 return (
479 <>
480 <span class="stat"><StatusLabel health={state()[0]}>{state()[1]}</StatusLabel></span>
481 <span class="stat" data-tip={[
482 sweep()?.endedAt && `took ${duration(sweep()!.endedAt! - sweep()!.startedAt)}`,
483 s().indexer.nextSweepAt && `next ${until(s().indexer.nextSweepAt!)}`,
484 ].filter(Boolean).join(", ") || undefined}>
485 <Show when={sweep()} fallback="never scanned">
486 {(last) => (last().endedAt ? <>scanned <b><Ago t={last().endedAt!} /></b></>
487 : <>scanning since <b><Ago t={last().startedAt} /></b></>)}
488 </Show>
489 <button class="button icon-only" aria-label="Rescan" disabled={off()}
490 data-tip={off() ? "the indexer is off" : "rescan…"} onClick={rescanDialog}>
491 <RotateCw size={12} />
492 </button>
493 </span>
494 </>
495 );
496 }}
497 </Show>
498 </div>
499 </div>
500 <Loaded data={stats} what="paper clover's index" retry={refetch} skeleton={
501 <div class="grid">
502 <div class="skeleton" style={{ height: "64px" }} />
503 <div class="skeleton" style={{ height: "150px" }} />
504 <div class="grid charts"><div class="skeleton" style={{ height: "260px" }} /><div class="skeleton" style={{ height: "260px" }} /></div>
505 </div>
506 }>
507 {(s) => (
508 <>
509 <Show when={s().failures.length || blocked().length || (s().indexer.enabled && !s().indexer.watching) || s().lastSweep?.error}>
510 <Issues stats={s()} browse={s().browse} blocked={blocked()} busy={busy} rescan={rescan} />
511 </Show>
512 <Running activity={activity} browse={s().browse} retry={activityControl.refetch} />
513 <Archive stats={s()} browse={s().browse} />
514 <div class="grid charts">
515 <Files stats={s()} browse={s().browse} />
516 <Processors stats={s()} />
517 </div>
518 </>
519 )}
520 </Loaded>
521 </div>
522 );
523}