Files
scrapy-ui/app/(dashboard)/jobs/page.tsx

388 lines
14 KiB
TypeScript
Raw Normal View History

"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> => {
const res = await fetch("/api/scrapyd/projects");
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> => {
const res = await fetch(`/api/scrapyd/jobs?project=${selectedProject}`);
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 }) => {
const res = await fetch("/api/scrapyd/jobs", {
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 ? (
<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>
</TableRow>
</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>
) : (
<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}>
<DialogContent className="max-w-[95vw] sm:max-w-[425px]">
<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>
<p className="text-sm break-all">
<strong>Job ID:</strong> <code>{selectedJob.id}</code>
</p>
</div>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setCancelDialogOpen(false)}
className="w-full sm:w-auto"
>
No, keep it
</Button>
<Button
variant="destructive"
onClick={handleCancelJob}
disabled={cancelJobMutation.isPending}
className="w-full sm:w-auto"
>
{cancelJobMutation.isPending ? "Canceling..." : "Yes, cancel job"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}