2025-11-05 03:32:14 +01:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import { Header } from "@/components/header";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
import {
|
|
|
|
|
Table,
|
|
|
|
|
TableBody,
|
|
|
|
|
TableCell,
|
|
|
|
|
TableHead,
|
|
|
|
|
TableHeader,
|
|
|
|
|
TableRow,
|
|
|
|
|
} from "@/components/ui/table";
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
import {
|
|
|
|
|
BriefcaseBusiness,
|
|
|
|
|
Clock,
|
|
|
|
|
PlayCircle,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
XCircle,
|
|
|
|
|
ExternalLink,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { ListProjects, ListJobs, Job } from "@/lib/types";
|
|
|
|
|
import { format } from "date-fns";
|
|
|
|
|
|
|
|
|
|
export default function JobsPage() {
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
|
|
|
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
|
|
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
|
|
|
|
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
|
|
|
|
|
|
|
|
|
// Fetch projects
|
|
|
|
|
const { data: projects, isLoading: isProjectsLoading } = useQuery({
|
|
|
|
|
queryKey: ["projects"],
|
|
|
|
|
queryFn: async (): Promise<ListProjects> => {
|
2025-11-05 06:29:58 +01:00
|
|
|
const res = await fetch("/api/scrapyd/projects");
|
2025-11-05 03:32:14 +01:00
|
|
|
if (!res.ok) throw new Error("Failed to fetch projects");
|
|
|
|
|
return res.json();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Fetch jobs for selected project
|
|
|
|
|
const { data: jobs, isLoading: isJobsLoading } = useQuery({
|
|
|
|
|
queryKey: ["jobs", selectedProject],
|
|
|
|
|
queryFn: async (): Promise<ListJobs> => {
|
2025-11-05 06:29:58 +01:00
|
|
|
const res = await fetch(`/api/scrapyd/jobs?project=${selectedProject}`);
|
2025-11-05 03:32:14 +01:00
|
|
|
if (!res.ok) throw new Error("Failed to fetch jobs");
|
|
|
|
|
return res.json();
|
|
|
|
|
},
|
|
|
|
|
enabled: !!selectedProject,
|
|
|
|
|
refetchInterval: 5000, // Refresh every 5 seconds for real-time updates
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Cancel job mutation
|
|
|
|
|
const cancelJobMutation = useMutation({
|
|
|
|
|
mutationFn: async (data: { project: string; job: string }) => {
|
2025-11-05 06:29:58 +01:00
|
|
|
const res = await fetch("/api/scrapyd/jobs", {
|
2025-11-05 03:32:14 +01:00
|
|
|
method: "DELETE",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error("Failed to cancel job");
|
|
|
|
|
return res.json();
|
|
|
|
|
},
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["jobs"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["daemon-status"] });
|
|
|
|
|
setCancelDialogOpen(false);
|
|
|
|
|
setSelectedJob(null);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleCancelJob = () => {
|
|
|
|
|
if (selectedJob) {
|
|
|
|
|
cancelJobMutation.mutate({
|
|
|
|
|
project: selectedProject,
|
|
|
|
|
job: selectedJob.id,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Combine and filter jobs
|
|
|
|
|
const allJobs: Array<Job & { status: string }> = [];
|
|
|
|
|
if (jobs) {
|
|
|
|
|
allJobs.push(...jobs.pending.map((j) => ({ ...j, status: "pending" })));
|
|
|
|
|
allJobs.push(...jobs.running.map((j) => ({ ...j, status: "running" })));
|
|
|
|
|
allJobs.push(...jobs.finished.map((j) => ({ ...j, status: "finished" })));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filteredJobs =
|
|
|
|
|
statusFilter === "all"
|
|
|
|
|
? allJobs
|
|
|
|
|
: allJobs.filter((j) => j.status === statusFilter);
|
|
|
|
|
|
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case "pending":
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="outline" className="gap-1">
|
|
|
|
|
<Clock className="h-3 w-3" />
|
|
|
|
|
Pending
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
case "running":
|
|
|
|
|
return (
|
|
|
|
|
<Badge className="gap-1 bg-green-500">
|
|
|
|
|
<PlayCircle className="h-3 w-3" />
|
|
|
|
|
Running
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
case "finished":
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="secondary" className="gap-1">
|
|
|
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
|
|
|
Finished
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
default:
|
|
|
|
|
return <Badge>{status}</Badge>;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<Header
|
|
|
|
|
title="Jobs"
|
|
|
|
|
description="Monitor and manage scraping jobs"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Filters */}
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Project</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{isProjectsLoading ? (
|
|
|
|
|
<Skeleton className="h-10 w-full" />
|
|
|
|
|
) : (
|
|
|
|
|
<Select value={selectedProject} onValueChange={setSelectedProject}>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="Select a project" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{projects?.projects.map((project) => (
|
|
|
|
|
<SelectItem key={project} value={project}>
|
|
|
|
|
{project}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Status Filter</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="all">All Jobs</SelectItem>
|
|
|
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
|
|
|
<SelectItem value="running">Running</SelectItem>
|
|
|
|
|
<SelectItem value="finished">Finished</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Jobs Statistics */}
|
|
|
|
|
{selectedProject && jobs && (
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Pending</CardTitle>
|
|
|
|
|
<Clock className="h-4 w-4 text-yellow-500" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{jobs.pending.length}</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Running</CardTitle>
|
|
|
|
|
<PlayCircle className="h-4 w-4 text-green-500" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{jobs.running.length}</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
|
|
|
<CardTitle className="text-sm font-medium">Finished</CardTitle>
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-blue-500" />
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="text-2xl font-bold">{jobs.finished.length}</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Jobs Table */}
|
|
|
|
|
{selectedProject && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>
|
|
|
|
|
Jobs for "{selectedProject}"
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{isJobsLoading ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{[1, 2, 3].map((i) => (
|
|
|
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : filteredJobs.length > 0 ? (
|
2025-11-05 05:47:41 +01:00
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead>Job ID</TableHead>
|
|
|
|
|
<TableHead>Spider</TableHead>
|
|
|
|
|
<TableHead>Status</TableHead>
|
|
|
|
|
<TableHead className="whitespace-nowrap">Start Time</TableHead>
|
|
|
|
|
<TableHead>PID</TableHead>
|
|
|
|
|
<TableHead className="text-right">Actions</TableHead>
|
2025-11-05 03:32:14 +01:00
|
|
|
</TableRow>
|
2025-11-05 05:47:41 +01:00
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{filteredJobs.map((job) => (
|
|
|
|
|
<TableRow key={job.id}>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<code className="rounded bg-muted px-2 py-1 text-xs whitespace-nowrap">
|
|
|
|
|
{job.id.substring(0, 8)}...
|
|
|
|
|
</code>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<div className="flex items-center gap-2 whitespace-nowrap">
|
|
|
|
|
<BriefcaseBusiness className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<span className="font-medium">{job.spider}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>{getStatusBadge(job.status)}</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
|
|
|
{format(new Date(job.start_time), "PPp")}
|
|
|
|
|
</span>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{job.pid ? (
|
|
|
|
|
<code className="text-xs">{job.pid}</code>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs text-muted-foreground">-</span>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-right">
|
|
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
|
|
|
{job.log_url && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
asChild
|
|
|
|
|
>
|
|
|
|
|
<a
|
|
|
|
|
href={job.log_url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
>
|
|
|
|
|
<ExternalLink className="h-4 w-4" />
|
|
|
|
|
</a>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{(job.status === "pending" || job.status === "running") && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedJob(job);
|
|
|
|
|
setCancelDialogOpen(true);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<XCircle className="h-4 w-4 text-destructive" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
2025-11-05 03:32:14 +01:00
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
|
|
|
<AlertCircle className="mb-4 h-12 w-12 text-muted-foreground" />
|
|
|
|
|
<h3 className="mb-2 text-lg font-semibold">No jobs found</h3>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{statusFilter === "all"
|
|
|
|
|
? "No jobs have been scheduled for this project"
|
|
|
|
|
: `No ${statusFilter} jobs found`}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!selectedProject && !isProjectsLoading && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
|
|
|
<BriefcaseBusiness className="mb-4 h-12 w-12 text-muted-foreground" />
|
|
|
|
|
<h3 className="mb-2 text-lg font-semibold">Select a project</h3>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Choose a project to view its jobs
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Cancel Job Dialog */}
|
|
|
|
|
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
2025-11-05 05:47:41 +01:00
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[425px]">
|
2025-11-05 03:32:14 +01:00
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Cancel Job</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
Are you sure you want to cancel this job?
|
|
|
|
|
{selectedJob && (
|
|
|
|
|
<div className="mt-2 rounded-lg border p-3">
|
|
|
|
|
<p className="text-sm">
|
|
|
|
|
<strong>Spider:</strong> {selectedJob.spider}
|
|
|
|
|
</p>
|
2025-11-05 05:47:41 +01:00
|
|
|
<p className="text-sm break-all">
|
2025-11-05 03:32:14 +01:00
|
|
|
<strong>Job ID:</strong> <code>{selectedJob.id}</code>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
2025-11-05 05:47:41 +01:00
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
2025-11-05 03:32:14 +01:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setCancelDialogOpen(false)}
|
2025-11-05 05:47:41 +01:00
|
|
|
className="w-full sm:w-auto"
|
2025-11-05 03:32:14 +01:00
|
|
|
>
|
|
|
|
|
No, keep it
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={handleCancelJob}
|
|
|
|
|
disabled={cancelJobMutation.isPending}
|
2025-11-05 05:47:41 +01:00
|
|
|
className="w-full sm:w-auto"
|
2025-11-05 03:32:14 +01:00
|
|
|
>
|
|
|
|
|
{cancelJobMutation.isPending ? "Canceling..." : "Yes, cancel job"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|