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:
+7
-4
@@ -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/<post>`
|
||||
- [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/<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 by MD5 from the library detail (manual post ID entry)
|
||||
- [ ] Batch cache status for the whole library (`matched` / `not_found` / `deleted`)
|
||||
|
||||
@@ -28,8 +28,8 @@ export function AppShell() {
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["status"],
|
||||
queryFn: () => api<SystemStatus>("/api/status/"),
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 5_000,
|
||||
staleTime: 4_000,
|
||||
});
|
||||
const status = statusQuery.data;
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
const OsIcon = osIcons[status.os.icon] ?? Monitor;
|
||||
|
||||
return (
|
||||
<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}`}
|
||||
>
|
||||
<span
|
||||
@@ -47,14 +76,19 @@ export function StatusPill({ status }: { status?: SystemStatus }) {
|
||||
{requestCount > 0 ? (
|
||||
<>
|
||||
<span className="text-ctp-surface2">·</span>
|
||||
<span
|
||||
className="text-ctp-teal"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{status.e621_time_ms !== null ? (
|
||||
@@ -65,6 +99,47 @@ export function StatusPill({ status }: { status?: SystemStatus }) {
|
||||
</span>
|
||||
</>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<E621MetricsState>((set) => ({
|
||||
requestCount: 0,
|
||||
totalMs: 0,
|
||||
last: null,
|
||||
history: [],
|
||||
|
||||
record: (path, ms) =>
|
||||
set((state) => ({
|
||||
set((state) => {
|
||||
const sample: E621RequestSample = { path, ms, at: Date.now() };
|
||||
return {
|
||||
requestCount: state.requestCount + 1,
|
||||
totalMs: state.totalMs + ms,
|
||||
last: { path, ms, at: Date.now() },
|
||||
})),
|
||||
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: [] }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user