From 75b7ed35eb84aee6d20033221ab66a60014e0a73 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 12:20:27 -0500 Subject: [PATCH] Live worker counts and an e621 request-history dropdown - Status polling drops from 30s to 5s, and download start/finish/cancel invalidates it immediately, so the footer's Active Workers reflects running download tasks in near real time - The e621 time in the status pill is now a button: it opens a dropdown with the session's request history (clock time, endpoint, duration, colour-coded) plus totals; closes on outside click or Escape - Metrics store keeps the last 50 requests --- ROADMAP.md | 11 ++- frontend/src/components/AppShell.tsx | 4 +- frontend/src/components/StatusPill.tsx | 87 +++++++++++++++++-- frontend/src/features/online/OnlineDetail.tsx | 22 ++++- frontend/src/store/e621Metrics.ts | 21 +++-- 5 files changed, 123 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 31176a7..0b07e8e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,10 +21,13 @@ they land. ## 2. e621 integration -- [ ] **Download progress bar on the detail view** - - [ ] Backend download task model (status, progress %, downloaded/total bytes, speed) - - [ ] Progress endpoint the SPA polls; cancel support - - [ ] Frontend progress bar with speed and cancel on `/detail/` +- [x] **Download progress bar on the detail view** + - [x] Backend download task (status, progress %, downloaded/total bytes, speed) + - [x] Progress endpoint the SPA polls; cancel support + - [x] Frontend progress bar with speed and cancel on `/detail/` + - [x] The status footer's "Active Workers" now counts running download tasks +- [x] **Download to client** — streams the e621 original straight to the + browser (works for guests; host-restricted proxy, no library write) - [ ] **Match local files to e621** - [ ] Match by MD5 from the library detail (manual post ID entry) - [ ] Batch cache status for the whole library (`matched` / `not_found` / `deleted`) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 54ad75e..1c4355b 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -28,8 +28,8 @@ export function AppShell() { const statusQuery = useQuery({ queryKey: ["status"], queryFn: () => api("/api/status/"), - refetchInterval: 30_000, - staleTime: 15_000, + refetchInterval: 5_000, + staleTime: 4_000, }); const status = statusQuery.data; diff --git a/frontend/src/components/StatusPill.tsx b/frontend/src/components/StatusPill.tsx index 9d449be..a3888b8 100644 --- a/frontend/src/components/StatusPill.tsx +++ b/frontend/src/components/StatusPill.tsx @@ -1,4 +1,5 @@ import { Laptop, Monitor, Terminal, type LucideIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/cn"; import type { SystemStatus } from "@/lib/types"; @@ -16,17 +17,45 @@ function formatMs(value: number): string { return `${value.toFixed(1)}ms`; } +function formatClock(at: number): string { + return new Date(at).toLocaleTimeString(); +} + export function StatusPill({ status }: { status?: SystemStatus }) { const requestCount = useE621Metrics((state) => state.requestCount); const totalMs = useE621Metrics((state) => state.totalMs); - const last = useE621Metrics((state) => state.last); + const history = useE621Metrics((state) => state.history); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handlePointerDown(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + } + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); if (!status) return null; const OsIcon = osIcons[status.os.icon] ?? Monitor; return ( 0 ? ( <> · - setOpen((value) => !value)} title={`${requestCount} e621 request${ requestCount === 1 ? "" : "s" - } this session · last ${last ? `${formatMs(last.ms)} (${last.path})` : "—"}`} + } this session — click for details`} + className={cn( + "text-ctp-teal transition hover:underline", + open && "underline", + )} > e621: {formatMs(totalMs)} - + ) : null} {status.e621_time_ms !== null ? ( @@ -65,6 +99,47 @@ export function StatusPill({ status }: { status?: SystemStatus }) { ) : null} + + {open && requestCount > 0 ? ( + + + e621 requests this session + + {requestCount} · {formatMs(totalMs)} + + + + {history.map((sample, index) => ( + + + {formatClock(sample.at)} + + + {sample.path} + + = 2000 + ? "text-ctp-peach" + : sample.ms >= 800 + ? "text-ctp-yellow" + : "text-ctp-teal", + )} + > + {formatMs(sample.ms)} + + + ))} + + + ) : null} ); } diff --git a/frontend/src/features/online/OnlineDetail.tsx b/frontend/src/features/online/OnlineDetail.tsx index 1014bb9..df791e0 100644 --- a/frontend/src/features/online/OnlineDetail.tsx +++ b/frontend/src/features/online/OnlineDetail.tsx @@ -129,7 +129,10 @@ export function OnlineDetail({ postId }: { postId: number }) { post: target, }, }), - onSuccess: (task) => setDownloadTaskId(task.task_id), + onSuccess: (task) => { + setDownloadTaskId(task.task_id); + void queryClient.invalidateQueries({ queryKey: ["status"] }); + }, }); const taskQuery = useQuery({ @@ -143,20 +146,31 @@ export function OnlineDetail({ postId }: { postId: number }) { }, }); const downloadTask = taskQuery.data; + const downloadStatus = downloadTask?.status; const downloadActive = - downloadTask?.status === "pending" || downloadTask?.status === "downloading"; + downloadStatus === "pending" || downloadStatus === "downloading"; useEffect(() => { - if (downloadTask?.status === "complete") { + if (!downloadStatus) return; + if ( + downloadStatus === "complete" || + downloadStatus === "error" || + downloadStatus === "cancelled" + ) { + void queryClient.invalidateQueries({ queryKey: ["status"] }); + } + if (downloadStatus === "complete") { void queryClient.invalidateQueries({ queryKey: ["library-lookup"] }); void queryClient.invalidateQueries({ queryKey: ["files"] }); } - }, [downloadTask?.status, queryClient]); + }, [downloadStatus, queryClient]); function cancelDownload() { if (!downloadTaskId) return; void api(`/api/online/downloads/${downloadTaskId}/cancel/`, { method: "POST", + }).then(() => { + void queryClient.invalidateQueries({ queryKey: ["status"] }); }); } diff --git a/frontend/src/store/e621Metrics.ts b/frontend/src/store/e621Metrics.ts index f429f3f..5decabf 100644 --- a/frontend/src/store/e621Metrics.ts +++ b/frontend/src/store/e621Metrics.ts @@ -6,10 +6,13 @@ export interface E621RequestSample { at: number; } +const HISTORY_LIMIT = 50; + interface E621MetricsState { requestCount: number; totalMs: number; last: E621RequestSample | null; + history: E621RequestSample[]; record: (path: string, ms: number) => void; reset: () => void; } @@ -19,13 +22,19 @@ export const useE621Metrics = create((set) => ({ requestCount: 0, totalMs: 0, last: null, + history: [], record: (path, ms) => - set((state) => ({ - requestCount: state.requestCount + 1, - totalMs: state.totalMs + ms, - last: { path, ms, at: Date.now() }, - })), + set((state) => { + const sample: E621RequestSample = { path, ms, at: Date.now() }; + return { + requestCount: state.requestCount + 1, + totalMs: state.totalMs + ms, + last: sample, + history: [sample, ...state.history].slice(0, HISTORY_LIMIT), + }; + }), - reset: () => set({ requestCount: 0, totalMs: 0, last: null }), + reset: () => + set({ requestCount: 0, totalMs: 0, last: null, history: [] }), }));