Keep the UI attached to background jobs across navigation

Jobs already run on the server — leaving the page or closing the tab does
not stop them — but the SPA lost its link to them because the task id
lived in component state. The online detail page now looks up the newest
task for the post: an active one resumes the progress bar and cancel
button, and a finished one shows "your last download for this post
finished — J-xx". The downloads list accepts a post_id filter for that
lookup.

The footer's Active Workers count is also a link to the staff stats
dashboard, which is the global view of running jobs.
This commit is contained in:
2026-09-17 21:45:24 -05:00
parent 27cbfd882c
commit 487d14c617
4 changed files with 87 additions and 19 deletions
+3
View File
@@ -420,6 +420,9 @@ class DownloadTaskViewSet(
def get_queryset(self): def get_queryset(self):
reap_stale_downloads() reap_stale_downloads()
queryset = DownloadTask.objects.select_related("library_item") queryset = DownloadTask.objects.select_related("library_item")
post_id = self.request.query_params.get("post_id", "").strip()
if post_id.isdigit():
queryset = queryset.filter(post_id=int(post_id))
user = self.request.user user = self.request.user
if not (user.is_staff or user.is_superuser): if not (user.is_staff or user.is_superuser):
queryset = queryset.filter(user=user) queryset = queryset.filter(user=user)
+6 -1
View File
@@ -175,7 +175,12 @@ export function AppShell() {
<Outlet /> <Outlet />
</main> </main>
<StatusFooter status={status} /> <StatusFooter
status={status}
staff={Boolean(
user && (user.is_staff || user.is_superuser || user.role === "staff"),
)}
/>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} /> <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div> </div>
); );
+35 -12
View File
@@ -1,8 +1,27 @@
import { Folder, Zap } from "lucide-react"; import { Folder, Zap } from "lucide-react";
import { Link } from "react-router-dom";
import type { SystemStatus } from "@/lib/types"; import type { SystemStatus } from "@/lib/types";
export function StatusFooter({ status }: { status?: SystemStatus }) { export function StatusFooter({
status,
staff,
}: {
status?: SystemStatus;
staff?: boolean;
}) {
const workers = (
<>
<Zap className="h-3 w-3" />
<span>
Active Workers:{" "}
{status
? `${status.workers.active} running | ${status.workers.queued} pending`
: "…"}
</span>
</>
);
return ( return (
<footer className="fixed inset-x-0 bottom-0 z-30 border-t border-ctp-surface0 bg-ctp-crust"> <footer className="fixed inset-x-0 bottom-0 z-30 border-t border-ctp-surface0 bg-ctp-crust">
<div className="mx-auto flex h-8 w-full max-w-[1600px] items-center gap-4 px-4 font-mono text-[11px] text-ctp-overlay0"> <div className="mx-auto flex h-8 w-full max-w-[1600px] items-center gap-4 px-4 font-mono text-[11px] text-ctp-overlay0">
@@ -15,18 +34,22 @@ export function StatusFooter({ status }: { status?: SystemStatus }) {
<span className="truncate">{status?.watched_folder ?? "…"}</span> <span className="truncate">{status?.watched_folder ?? "…"}</span>
</span> </span>
<span {staff ? (
className="ml-auto hidden shrink-0 items-center gap-1.5 md:flex" <Link
title="Active background jobs" to="/stats"
> className="ml-auto hidden shrink-0 items-center gap-1.5 transition hover:text-ctp-text md:flex"
<Zap className="h-3 w-3" /> title="Open the stats dashboard"
<span> >
Active Workers:{" "} {workers}
{status </Link>
? `${status.workers.active} running | ${status.workers.queued} pending` ) : (
: "…"} <span
className="ml-auto hidden shrink-0 items-center gap-1.5 md:flex"
title="Active background jobs"
>
{workers}
</span> </span>
</span> )}
</div> </div>
</footer> </footer>
); );
+43 -6
View File
@@ -28,7 +28,7 @@ import {
TAG_CATEGORY_ORDER, TAG_CATEGORY_ORDER,
} from "@/lib/e621"; } from "@/lib/e621";
import { formatBytes, formatDate } from "@/lib/format"; import { formatBytes, formatDate } from "@/lib/format";
import type { DownloadTask } from "@/lib/types"; import type { DownloadTask, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621"; import { useE621 } from "@/store/e621";
@@ -119,6 +119,25 @@ export function OnlineDetail({ postId }: { postId: number }) {
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null); const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
// Server-side tasks outlive this page; re-attach to the newest one for this
// post so navigating away and back keeps the progress view.
const tasksQuery = useQuery({
queryKey: ["download-tasks", postId],
queryFn: () =>
api<Paginated<DownloadTask>>(
`/api/online/downloads/?post_id=${postId}`,
),
enabled: Boolean(user?.can_upload),
staleTime: 10_000,
});
const remoteActiveTask = tasksQuery.data?.results.find(
(task) => task.status === "pending" || task.status === "downloading",
);
const remoteFinishedTask = tasksQuery.data?.results.find(
(task) => task.status === "complete",
);
const effectiveTaskId = downloadTaskId ?? remoteActiveTask?.task_id ?? null;
useEffect(() => { useEffect(() => {
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.ctrlKey || event.metaKey || event.altKey) return; if (event.ctrlKey || event.metaKey || event.altKey) return;
@@ -150,10 +169,10 @@ export function OnlineDetail({ postId }: { postId: number }) {
}); });
const taskQuery = useQuery({ const taskQuery = useQuery({
queryKey: ["download-task", downloadTaskId], queryKey: ["download-task", effectiveTaskId],
queryFn: () => queryFn: () =>
api<DownloadTask>(`/api/online/downloads/${downloadTaskId}/`), api<DownloadTask>(`/api/online/downloads/${effectiveTaskId}/`),
enabled: Boolean(downloadTaskId), enabled: Boolean(effectiveTaskId),
refetchInterval: (query) => { refetchInterval: (query) => {
const status = query.state.data?.status; const status = query.state.data?.status;
return status === "pending" || status === "downloading" ? 700 : false; return status === "pending" || status === "downloading" ? 700 : false;
@@ -176,15 +195,17 @@ export function OnlineDetail({ postId }: { postId: number }) {
if (downloadStatus === "complete") { 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"] });
void queryClient.invalidateQueries({ queryKey: ["download-tasks"] });
} }
}, [downloadStatus, queryClient]); }, [downloadStatus, queryClient]);
function cancelDownload() { function cancelDownload() {
if (!downloadTaskId) return; if (!effectiveTaskId) return;
void api(`/api/online/downloads/${downloadTaskId}/cancel/`, { void api(`/api/online/downloads/${effectiveTaskId}/cancel/`, {
method: "POST", method: "POST",
}).then(() => { }).then(() => {
void queryClient.invalidateQueries({ queryKey: ["status"] }); void queryClient.invalidateQueries({ queryKey: ["status"] });
void queryClient.invalidateQueries({ queryKey: ["download-tasks"] });
}); });
} }
@@ -362,6 +383,22 @@ export function OnlineDetail({ postId }: { postId: number }) {
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red"> <p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{downloadTask.error || "Download failed."} {downloadTask.error || "Download failed."}
</p> </p>
) : remoteFinishedTask && !downloadMutation.isPending ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-ctp-green" />
Your last download for this post finished
{remoteFinishedTask.library_j_id ? (
<>
{" — "}
<Link
to={`/detail/${remoteFinishedTask.library_j_id}`}
className="font-mono text-ctp-blue hover:underline"
>
{remoteFinishedTask.library_j_id}
</Link>
</>
) : null}
</p>
) : null} ) : null}
{favoriteMutation.isError ? ( {favoriteMutation.isError ? (