| 1 | import RotateCw from "lucide-solid/icons/rotate-cw"; |
| 2 | import { createSignal, For, Index, onCleanup, type Resource, Show } from "solid-js"; |
| 3 | import { A } from "@solidjs/router"; |
| 4 | import { parseResponse } from "hono/client"; |
| 5 | import type { PaperCloverStats, ProgressNode } from "../types/paperClover.ts"; |
| 6 | import { api, queries, unconnected } from "../api.ts"; |
| 7 | import { Ago } from "../components/Ago.tsx"; |
| 8 | import { showTextDialog } from "../components/Dialog.tsx"; |
| 9 | import { lastGood, Loaded } from "../components/Loaded.tsx"; |
| 10 | import { type StatusKind, StatusLabel } from "../components/Status.tsx"; |
| 11 | import { TabBar } from "../components/TabBar.tsx"; |
| 12 | import { toast } from "../components/Toast.tsx"; |
| 13 | import { bytes, clock, count, datetime, duration, percent, plural, until } from "../format.ts"; |
| 14 | import "./PaperClover.css"; |
| 15 | |
| 16 | const SITE = "https://paperclover.net/file"; |
| 17 | /** The runtime side of the source of truth: its logs, restarts and resources. */ |
| 18 | const SERVICE = "/services/clover-source-of-truth"; |
| 19 | /** Lines a progress node lists before folding the rest into "[N more]". */ |
| 20 | const SHOWN = 6; |
| 21 | |
| 22 | const scan = (path: string | null) => parseResponse(api["paper-clover"].scan.$post({ json: { path } })); |
| 23 | const 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 | */ |
| 29 | function 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 | |
| 42 | function 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 | |
| 52 | function 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. */ |
| 149 | function 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]". */ |
| 163 | function 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 | |
| 230 | function 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". */ |
| 247 | const 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 | */ |
| 253 | function 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. */ |
| 267 | function 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 | |
| 349 | function 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 | |
| 395 | function 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 | |
| 428 | export 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 | } |