UI: job badges, skeletons, cancel support + API route to cancel jobs\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,7 @@ export default function JobHistory({ ticker }: Props) {
|
|||||||
const [jobs, setJobs] = useState<any[]>([]);
|
const [jobs, setJobs] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selected, setSelected] = useState<any | null>(null);
|
const [selected, setSelected] = useState<any | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -28,6 +29,21 @@ export default function JobHistory({ ticker }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancel = async (jobId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/jobs/${jobId}/cancel`, { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
// Refresh list
|
||||||
|
fetchJobs();
|
||||||
|
return data.cancelled === true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Cancel failed:", e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchJobs();
|
fetchJobs();
|
||||||
const id = setInterval(fetchJobs, 8000);
|
const id = setInterval(fetchJobs, 8000);
|
||||||
@@ -58,7 +74,13 @@ export default function JobHistory({ ticker }: Props) {
|
|||||||
<span className="text-xs text-gray-500">{loading ? "Loading..." : `${jobs.length} jobs`}</span>
|
<span className="text-xs text-gray-500">{loading ? "Loading..." : `${jobs.length} jobs`}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{jobs.length === 0 ? (
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||||
|
<div className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||||
|
<div className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : jobs.length === 0 ? (
|
||||||
<p className="text-gray-500">No recent jobs for {ticker}</p>
|
<p className="text-gray-500">No recent jobs for {ticker}</p>
|
||||||
) : (
|
) : (
|
||||||
jobs.map((j: any) => (
|
jobs.map((j: any) => (
|
||||||
@@ -66,7 +88,7 @@ export default function JobHistory({ ticker }: Props) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium">Job: <span className="text-blue-600">{j.id}</span></div>
|
<div className="font-medium">Job: <span className="text-blue-600">{j.id}</span></div>
|
||||||
<div className="text-xs text-gray-600">State: <strong>{j.state}</strong></div>
|
<div className="text-xs text-gray-600">State: <strong className={`px-2 py-0.5 rounded text-xs ${j.state === 'completed' ? 'bg-green-100 text-green-800' : j.state === 'failed' ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}>{j.state}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a href={`/api/jobs/${j.id}`} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline">API</a>
|
<a href={`/api/jobs/${j.id}`} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline">API</a>
|
||||||
@@ -76,6 +98,14 @@ export default function JobHistory({ ticker }: Props) {
|
|||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
|
{(j.state === 'waiting' || j.state === 'queued') && (
|
||||||
|
<button
|
||||||
|
onClick={() => cancel(j.id)}
|
||||||
|
className="text-sm text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -12,6 +12,7 @@ let worker: any = undefined;
|
|||||||
let enqueueAnalyze: (ticker: string, input: any) => Promise<string> | string;
|
let enqueueAnalyze: (ticker: string, input: any) => Promise<string> | string;
|
||||||
let getJob: (jobId: string) => Promise<any | null>;
|
let getJob: (jobId: string) => Promise<any | null>;
|
||||||
let listRecentJobs: (ticker?: string, limit?: number) => Promise<any[]>;
|
let listRecentJobs: (ticker?: string, limit?: number) => Promise<any[]>;
|
||||||
|
let cancelJob: (jobId: string) => Promise<boolean>;
|
||||||
|
|
||||||
if (REDIS_URL) {
|
if (REDIS_URL) {
|
||||||
const redis = new IORedis(REDIS_URL as string);
|
const redis = new IORedis(REDIS_URL as string);
|
||||||
@@ -86,6 +87,22 @@ if (REDIS_URL) {
|
|||||||
if (ticker) return mapped.filter((j: any) => j.data?.ticker === ticker);
|
if (ticker) return mapped.filter((j: any) => j.data?.ticker === ticker);
|
||||||
return mapped;
|
return mapped;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
cancelJob = async (jobId: string) => {
|
||||||
|
try {
|
||||||
|
const job = await analyzeQueue.getJob(jobId);
|
||||||
|
if (!job) return false;
|
||||||
|
const state = await job.getState();
|
||||||
|
if (state === "waiting" || state === "delayed") {
|
||||||
|
await job.remove();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("cancelJob error:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
// In-process fallback queue for environments without Redis (dev/tests)
|
// In-process fallback queue for environments without Redis (dev/tests)
|
||||||
type Job = { id: string; ticker: string; input: any; state: "queued" | "processing" | "completed" | "failed"; result?: any; failedReason?: string };
|
type Job = { id: string; ticker: string; input: any; state: "queued" | "processing" | "completed" | "failed"; result?: any; failedReason?: string };
|
||||||
@@ -112,6 +129,20 @@ if (REDIS_URL) {
|
|||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
cancelJob = async (jobId: string) => {
|
||||||
|
const job = jobsById[jobId];
|
||||||
|
if (!job) return false;
|
||||||
|
// If queued but not yet processing, remove from queue
|
||||||
|
if (job.state === "queued") {
|
||||||
|
const idx = queue.findIndex((q) => q.id === jobId);
|
||||||
|
if (idx !== -1) queue.splice(idx, 1);
|
||||||
|
job.state = "failed";
|
||||||
|
job.failedReason = "cancelled";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Can't cancel if already processing/completed/failed
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
async function processQueue() {
|
async function processQueue() {
|
||||||
processing = true;
|
processing = true;
|
||||||
@@ -168,5 +199,5 @@ if (REDIS_URL) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { enqueueAnalyze, getJob, listRecentJobs, analyzeQueue, worker };
|
export { enqueueAnalyze, getJob, listRecentJobs, cancelJob, analyzeQueue, worker };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { cancelJob } from "../../../lib/queue";
|
||||||
|
|
||||||
|
export async function action({ params }: { params: { jobId: string } }) {
|
||||||
|
const jobId = params.jobId;
|
||||||
|
if (!jobId) return Response.json({ error: "jobId required" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await cancelJob(jobId);
|
||||||
|
return Response.json({ cancelled: ok });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/api/jobs cancel error:", err);
|
||||||
|
return Response.json({ error: "internal" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,11 +58,37 @@ export default function JobDetail() {
|
|||||||
{job?.failedReason && (
|
{job?.failedReason && (
|
||||||
<div className="mb-3 text-red-600">Failed: {job.failedReason}</div>
|
<div className="mb-3 text-red-600">Failed: {job.failedReason}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
{job?.state === 'waiting' || job?.state === 'queued' ? (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/jobs/${job?.id}/cancel`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.cancelled) {
|
||||||
|
setJob((prev: any) => ({ ...(prev || {}), state: 'failed', failedReason: 'cancelled' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
Cancel Job
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<a href={`/api/jobs/${job?.id}`} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline">Open API</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-700 mb-3">Data:</div>
|
<div className="text-sm text-gray-700 mb-3">Data:</div>
|
||||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto">{JSON.stringify(job?.data || job?.returnValue || job, null, 2)}</pre>
|
<pre className="bg-gray-50 p-3 rounded text-xs overflow-x-auto">{JSON.stringify(job?.data || job?.returnValue || job, null, 2)}</pre>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<a href={`/api/jobs/${job?.id}`} target="_blank" rel="noreferrer" className="text-sm text-blue-600 hover:underline">Open API</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user