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
This commit is contained in:
2026-09-17 12:20:27 -05:00
parent 6962e483fc
commit 75b7ed35eb
5 changed files with 123 additions and 22 deletions
+7 -4
View File
@@ -21,10 +21,13 @@ they land.
## 2. e621 integration ## 2. e621 integration
- [ ] **Download progress bar on the detail view** - [x] **Download progress bar on the detail view**
- [ ] Backend download task model (status, progress %, downloaded/total bytes, speed) - [x] Backend download task (status, progress %, downloaded/total bytes, speed)
- [ ] Progress endpoint the SPA polls; cancel support - [x] Progress endpoint the SPA polls; cancel support
- [ ] Frontend progress bar with speed and cancel on `/detail/<post>` - [x] Frontend progress bar with speed and cancel on `/detail/<post>`
- [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 local files to e621**
- [ ] Match by MD5 from the library detail (manual post ID entry) - [ ] Match by MD5 from the library detail (manual post ID entry)
- [ ] Batch cache status for the whole library (`matched` / `not_found` / `deleted`) - [ ] Batch cache status for the whole library (`matched` / `not_found` / `deleted`)
+2 -2
View File
@@ -28,8 +28,8 @@ export function AppShell() {
const statusQuery = useQuery({ const statusQuery = useQuery({
queryKey: ["status"], queryKey: ["status"],
queryFn: () => api<SystemStatus>("/api/status/"), queryFn: () => api<SystemStatus>("/api/status/"),
refetchInterval: 30_000, refetchInterval: 5_000,
staleTime: 15_000, staleTime: 4_000,
}); });
const status = statusQuery.data; const status = statusQuery.data;
+81 -6
View File
@@ -1,4 +1,5 @@
import { Laptop, Monitor, Terminal, type LucideIcon } from "lucide-react"; import { Laptop, Monitor, Terminal, type LucideIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import type { SystemStatus } from "@/lib/types"; import type { SystemStatus } from "@/lib/types";
@@ -16,17 +17,45 @@ function formatMs(value: number): string {
return `${value.toFixed(1)}ms`; return `${value.toFixed(1)}ms`;
} }
function formatClock(at: number): string {
return new Date(at).toLocaleTimeString();
}
export function StatusPill({ status }: { status?: SystemStatus }) { export function StatusPill({ status }: { status?: SystemStatus }) {
const requestCount = useE621Metrics((state) => state.requestCount); const requestCount = useE621Metrics((state) => state.requestCount);
const totalMs = useE621Metrics((state) => state.totalMs); 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<HTMLSpanElement>(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; if (!status) return null;
const OsIcon = osIcons[status.os.icon] ?? Monitor; const OsIcon = osIcons[status.os.icon] ?? Monitor;
return ( return (
<span <span
className="hidden select-none items-center gap-1.5 rounded-full border border-ctp-surface0 bg-ctp-mantle px-2.5 py-1 font-mono text-[11px] text-ctp-overlay1 lg:flex" ref={containerRef}
className="relative hidden select-none items-center gap-1.5 rounded-full border border-ctp-surface0 bg-ctp-mantle px-2.5 py-1 font-mono text-[11px] text-ctp-overlay1 lg:flex"
title={`${status.os.name} ${status.os.release} · built from ${status.git_hash}`} title={`${status.os.name} ${status.os.release} · built from ${status.git_hash}`}
> >
<span <span
@@ -47,14 +76,19 @@ export function StatusPill({ status }: { status?: SystemStatus }) {
{requestCount > 0 ? ( {requestCount > 0 ? (
<> <>
<span className="text-ctp-surface2">·</span> <span className="text-ctp-surface2">·</span>
<span <button
className="text-ctp-teal" type="button"
onClick={() => setOpen((value) => !value)}
title={`${requestCount} e621 request${ title={`${requestCount} e621 request${
requestCount === 1 ? "" : "s" 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)} e621: {formatMs(totalMs)}
</span> </button>
</> </>
) : null} ) : null}
{status.e621_time_ms !== null ? ( {status.e621_time_ms !== null ? (
@@ -65,6 +99,47 @@ export function StatusPill({ status }: { status?: SystemStatus }) {
</span> </span>
</> </>
) : null} ) : null}
{open && requestCount > 0 ? (
<span className="absolute right-0 top-full z-50 mt-2 block w-80 cursor-default overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-left shadow-[0_4px_12px_rgba(17,17,27,0.6)]">
<span className="flex items-center justify-between gap-2 border-b border-ctp-surface1 px-3 py-2 text-[11px] text-ctp-overlay0">
<span className="font-sans">e621 requests this session</span>
<span className="font-mono">
{requestCount} · {formatMs(totalMs)}
</span>
</span>
<span className="block max-h-72 overflow-y-auto py-1">
{history.map((sample, index) => (
<span
key={`${sample.at}-${index}`}
className="flex items-center gap-2 px-3 py-1 font-mono text-[11px]"
>
<span className="shrink-0 text-ctp-overlay0">
{formatClock(sample.at)}
</span>
<span
className="min-w-0 flex-1 truncate text-ctp-subtext0"
title={sample.path}
>
{sample.path}
</span>
<span
className={cn(
"shrink-0",
sample.ms >= 2000
? "text-ctp-peach"
: sample.ms >= 800
? "text-ctp-yellow"
: "text-ctp-teal",
)}
>
{formatMs(sample.ms)}
</span>
</span>
))}
</span>
</span>
) : null}
</span> </span>
); );
} }
+18 -4
View File
@@ -129,7 +129,10 @@ export function OnlineDetail({ postId }: { postId: number }) {
post: target, post: target,
}, },
}), }),
onSuccess: (task) => setDownloadTaskId(task.task_id), onSuccess: (task) => {
setDownloadTaskId(task.task_id);
void queryClient.invalidateQueries({ queryKey: ["status"] });
},
}); });
const taskQuery = useQuery({ const taskQuery = useQuery({
@@ -143,20 +146,31 @@ export function OnlineDetail({ postId }: { postId: number }) {
}, },
}); });
const downloadTask = taskQuery.data; const downloadTask = taskQuery.data;
const downloadStatus = downloadTask?.status;
const downloadActive = const downloadActive =
downloadTask?.status === "pending" || downloadTask?.status === "downloading"; downloadStatus === "pending" || downloadStatus === "downloading";
useEffect(() => { 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: ["library-lookup"] });
void queryClient.invalidateQueries({ queryKey: ["files"] }); void queryClient.invalidateQueries({ queryKey: ["files"] });
} }
}, [downloadTask?.status, queryClient]); }, [downloadStatus, queryClient]);
function cancelDownload() { function cancelDownload() {
if (!downloadTaskId) return; if (!downloadTaskId) return;
void api(`/api/online/downloads/${downloadTaskId}/cancel/`, { void api(`/api/online/downloads/${downloadTaskId}/cancel/`, {
method: "POST", method: "POST",
}).then(() => {
void queryClient.invalidateQueries({ queryKey: ["status"] });
}); });
} }
+15 -6
View File
@@ -6,10 +6,13 @@ export interface E621RequestSample {
at: number; at: number;
} }
const HISTORY_LIMIT = 50;
interface E621MetricsState { interface E621MetricsState {
requestCount: number; requestCount: number;
totalMs: number; totalMs: number;
last: E621RequestSample | null; last: E621RequestSample | null;
history: E621RequestSample[];
record: (path: string, ms: number) => void; record: (path: string, ms: number) => void;
reset: () => void; reset: () => void;
} }
@@ -19,13 +22,19 @@ export const useE621Metrics = create<E621MetricsState>((set) => ({
requestCount: 0, requestCount: 0,
totalMs: 0, totalMs: 0,
last: null, last: null,
history: [],
record: (path, ms) => record: (path, ms) =>
set((state) => ({ set((state) => {
requestCount: state.requestCount + 1, const sample: E621RequestSample = { path, ms, at: Date.now() };
totalMs: state.totalMs + ms, return {
last: { path, ms, at: Date.now() }, 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: [] }),
})); }));