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:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user