feat: initial commit - Scrapyd UI web interface
- Next.js 16.0.1 + React 19.2.0 + Tailwind CSS 4.1.16 - Complete Scrapyd API integration (all 12 endpoints) - Dashboard with real-time job monitoring - Projects management (upload, list, delete) - Spiders management with scheduling - Jobs monitoring with filtering and cancellation - System status monitoring - Dark/light theme toggle with next-themes - Server-side authentication via environment variables - Docker deployment with multi-stage builds - GitHub Actions CI/CD workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
383
app/(dashboard)/jobs/page.tsx
Normal file
383
app/(dashboard)/jobs/page.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"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("/ui/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(`/ui/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("/ui/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 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Job ID</TableHead>
|
||||
<TableHead>Spider</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>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">
|
||||
{job.id.substring(0, 8)}...
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
{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 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>
|
||||
<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">
|
||||
<strong>Job ID:</strong> <code>{selectedJob.id}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancelDialogOpen(false)}
|
||||
>
|
||||
No, keep it
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancelJob}
|
||||
disabled={cancelJobMutation.isPending}
|
||||
>
|
||||
{cancelJobMutation.isPending ? "Canceling..." : "Yes, cancel job"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user