Add job cancellation to the stats dashboard
- Active jobs on /stats get a cancel button wired to the existing download/match cancel endpoints, showing "cancelling..." and an inline error when the task already finished. - Cancelling now sets the status immediately, so a task whose runner died in a restart stops showing as "downloading". - Download streams use a bounded read timeout (10 s connect / 60 s read): a stalled socket fails within a minute (previously it could block forever), and a task cancelled while stalled is marked cancelled rather than error. - The stats job list reaps stale download/match tasks, so phantom jobs never appear on the dashboard.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Download, Gauge, HardDrive, MemoryStick, ScanSearch, Terminal } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { EmptyState, Spinner } from "@/components/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { StatJob, SystemStats } from "@/lib/types";
|
||||
@@ -77,7 +77,19 @@ function jobStatusClass(status: string): string {
|
||||
return "text-ctp-blue";
|
||||
}
|
||||
|
||||
function JobRow({ job, showSummary }: { job: StatJob; showSummary?: boolean }) {
|
||||
function JobRow({
|
||||
job,
|
||||
showSummary,
|
||||
onCancel,
|
||||
cancelling,
|
||||
cancelError,
|
||||
}: {
|
||||
job: StatJob;
|
||||
showSummary?: boolean;
|
||||
onCancel?: () => void;
|
||||
cancelling?: boolean;
|
||||
cancelError?: string | null;
|
||||
}) {
|
||||
const Icon = job.kind === "download" ? Download : ScanSearch;
|
||||
return (
|
||||
<li className="flex flex-col gap-1">
|
||||
@@ -87,7 +99,21 @@ function JobRow({ job, showSummary }: { job: StatJob; showSummary?: boolean }) {
|
||||
<span className={cn("ml-auto shrink-0 font-mono text-[11px]", jobStatusClass(job.status))}>
|
||||
{job.status}
|
||||
</span>
|
||||
{onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={cancelling || job.cancelled}
|
||||
className="shrink-0 rounded border border-ctp-red/40 px-1.5 py-0.5 font-mono text-[10px] text-ctp-red transition hover:bg-ctp-red/15 disabled:opacity-50"
|
||||
title="Cancel this job"
|
||||
>
|
||||
{job.cancelled || cancelling ? "cancelling…" : "cancel"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{cancelError ? (
|
||||
<p className="pl-5 text-[11px] text-ctp-red">{cancelError}</p>
|
||||
) : null}
|
||||
{showSummary ? (
|
||||
<p className="truncate pl-5 text-[11px] text-ctp-overlay0" title={job.summary ?? job.detail}>
|
||||
{job.summary ?? job.detail}
|
||||
@@ -114,6 +140,7 @@ function logLineClass(line: string): string {
|
||||
|
||||
export default function StatsPage() {
|
||||
const user = useAuth((state) => state.user);
|
||||
const queryClient = useQueryClient();
|
||||
const staff = Boolean(
|
||||
user &&
|
||||
(user.is_staff || user.is_superuser || user.role === "staff"),
|
||||
@@ -124,6 +151,19 @@ export default function StatsPage() {
|
||||
enabled: staff,
|
||||
refetchInterval: staff ? REFRESH_MS : false,
|
||||
});
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (job: StatJob) =>
|
||||
api(
|
||||
job.kind === "download"
|
||||
? `/api/online/downloads/${job.id}/cancel/`
|
||||
: `/api/matches/${job.id}/cancel/`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["stats"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
},
|
||||
});
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const data = query.data;
|
||||
@@ -321,7 +361,21 @@ export default function StatsPage() {
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{jobs.active.map((job) => (
|
||||
<JobRow key={`${job.kind}-${job.id}`} job={job} />
|
||||
<JobRow
|
||||
key={`${job.kind}-${job.id}`}
|
||||
job={job}
|
||||
onCancel={() => cancelMutation.mutate(job)}
|
||||
cancelling={
|
||||
cancelMutation.isPending &&
|
||||
cancelMutation.variables?.id === job.id
|
||||
}
|
||||
cancelError={
|
||||
cancelMutation.isError &&
|
||||
cancelMutation.variables?.id === job.id
|
||||
? errorMessage(cancelMutation.error)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user