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
|
## 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`)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const sample: E621RequestSample = { path, ms, at: Date.now() };
|
||||||
|
return {
|
||||||
requestCount: state.requestCount + 1,
|
requestCount: state.requestCount + 1,
|
||||||
totalMs: state.totalMs + ms,
|
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