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:
2025-11-05 03:32:14 +01:00
commit 971ef5426d
55 changed files with 8885 additions and 0 deletions

View 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>
);
}

View File

@@ -0,0 +1,19 @@
import { Sidebar } from "@/components/sidebar";
import { Providers } from "@/components/providers";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Providers>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
<div className="container p-6">{children}</div>
</main>
</div>
</Providers>
);
}

173
app/(dashboard)/page.tsx Normal file
View File

@@ -0,0 +1,173 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Header } from "@/components/header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Activity,
FolderKanban,
PlayCircle,
Clock,
CheckCircle2,
} from "lucide-react";
import { DaemonStatus } from "@/lib/types";
export default function DashboardPage() {
const { data: daemonStatus, isLoading: isDaemonLoading } = useQuery({
queryKey: ["daemon-status"],
queryFn: async (): Promise<DaemonStatus> => {
const res = await fetch("/ui/api/scrapyd/daemon");
if (!res.ok) throw new Error("Failed to fetch daemon status");
return res.json();
},
});
const { data: projects, isLoading: isProjectsLoading } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const res = await fetch("/ui/api/scrapyd/projects");
if (!res.ok) throw new Error("Failed to fetch projects");
return res.json();
},
});
const stats = [
{
title: "Running Jobs",
value: daemonStatus?.running ?? 0,
icon: PlayCircle,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
title: "Pending Jobs",
value: daemonStatus?.pending ?? 0,
icon: Clock,
color: "text-yellow-500",
bgColor: "bg-yellow-500/10",
},
{
title: "Finished Jobs",
value: daemonStatus?.finished ?? 0,
icon: CheckCircle2,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
title: "Total Projects",
value: projects?.projects?.length ?? 0,
icon: FolderKanban,
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
];
return (
<div className="space-y-6">
<Header
title="Dashboard"
description="Monitor your Scrapyd instance and scraping jobs"
/>
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between gap-0 pb-2">
<CardTitle className="text-sm font-medium">
{stat.title}
</CardTitle>
<div className={`rounded-full p-2 ${stat.bgColor}`}>
<stat.icon className={`h-4 w-4 ${stat.color}`} />
</div>
</CardHeader>
<CardContent>
{isDaemonLoading || isProjectsLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<div className="text-2xl font-bold">{stat.value}</div>
)}
</CardContent>
</Card>
))}
</div>
{/* System Status Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>System Status</CardTitle>
<Badge variant="outline" className="flex items-center gap-1">
<Activity className="h-3 w-3" />
{isDaemonLoading ? (
<Skeleton className="h-4 w-12" />
) : (
daemonStatus?.status
)}
</Badge>
</div>
</CardHeader>
<CardContent>
{isDaemonLoading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Node Name:</span>
<span className="font-mono">{daemonStatus?.node_name}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Jobs:</span>
<span>
{(daemonStatus?.running ?? 0) +
(daemonStatus?.pending ?? 0) +
(daemonStatus?.finished ?? 0)}
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Projects Overview */}
<Card>
<CardHeader>
<CardTitle>Projects Overview</CardTitle>
</CardHeader>
<CardContent>
{isProjectsLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : projects?.projects?.length > 0 ? (
<div className="space-y-2">
{projects.projects.map((project: string) => (
<div
key={project}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2">
<FolderKanban className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{project}</span>
</div>
<Badge variant="secondary">Active</Badge>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground">
No projects found. Upload a project to get started.
</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,326 @@
"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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
FolderKanban,
Upload,
Trash2,
Package,
AlertCircle,
} from "lucide-react";
import { ListProjects, ListVersions } from "@/lib/types";
export default function ProjectsPage() {
const queryClient = useQueryClient();
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// 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 versions for selected project
const { data: versions } = useQuery({
queryKey: ["versions", selectedProject],
queryFn: async (): Promise<ListVersions> => {
const res = await fetch(
`/ui/api/scrapyd/versions?project=${selectedProject}`
);
if (!res.ok) throw new Error("Failed to fetch versions");
return res.json();
},
enabled: !!selectedProject,
});
// Delete project mutation
const deleteProjectMutation = useMutation({
mutationFn: async (project: string) => {
const res = await fetch("/ui/api/scrapyd/projects", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project }),
});
if (!res.ok) throw new Error("Failed to delete project");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setDeleteDialogOpen(false);
setSelectedProject(null);
},
});
// Upload version mutation
const uploadVersionMutation = useMutation({
mutationFn: async (formData: FormData) => {
const res = await fetch("/ui/api/scrapyd/versions", {
method: "POST",
body: formData,
});
if (!res.ok) throw new Error("Failed to upload version");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["versions"] });
setUploadDialogOpen(false);
},
});
const handleUpload = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
uploadVersionMutation.mutate(formData);
};
return (
<div className="space-y-6">
<Header
title="Projects"
description="Manage your Scrapyd projects and versions"
action={
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
<DialogTrigger asChild>
<Button>
<Upload className="mr-2 h-4 w-4" />
Upload Project
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleUpload}>
<DialogHeader>
<DialogTitle>Upload Project Version</DialogTitle>
<DialogDescription>
Upload a Python egg file for your Scrapy project
</DialogDescription>
</DialogHeader>
<div className="gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="project">Project Name</Label>
<Input
id="project"
name="project"
placeholder="myproject"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="version">Version</Label>
<Input
id="version"
name="version"
placeholder="1.0.0"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="egg">Egg File</Label>
<Input
id="egg"
name="egg"
type="file"
accept=".egg"
required
/>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={uploadVersionMutation.isPending}
>
{uploadVersionMutation.isPending
? "Uploading..."
: "Upload"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
}
/>
{/* Projects List */}
<Card>
<CardHeader>
<CardTitle>All Projects</CardTitle>
</CardHeader>
<CardContent>
{isProjectsLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : projects?.projects && projects.projects.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Project Name</TableHead>
<TableHead>Versions</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.projects.map((project) => (
<TableRow
key={project}
onClick={() => setSelectedProject(project)}
className="cursor-pointer"
>
<TableCell>
<div className="flex items-center gap-2">
<FolderKanban className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{project}</span>
</div>
</TableCell>
<TableCell>
{selectedProject === project && versions ? (
<Badge variant="secondary">
{versions.versions.length} version(s)
</Badge>
) : (
<Badge variant="outline">Click to load</Badge>
)}
</TableCell>
<TableCell className="text-right">
<Dialog
open={deleteDialogOpen && selectedProject === project}
onOpenChange={(open) => {
setDeleteDialogOpen(open);
if (open) setSelectedProject(project);
}}
>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setSelectedProject(project);
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Project</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{project}"? This
action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() =>
deleteProjectMutation.mutate(project)
}
disabled={deleteProjectMutation.isPending}
>
{deleteProjectMutation.isPending
? "Deleting..."
: "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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 projects found</h3>
<p className="mb-4 text-sm text-muted-foreground">
Upload your first project to get started
</p>
<Button onClick={() => setUploadDialogOpen(true)}>
<Upload className="mr-2 h-4 w-4" />
Upload Project
</Button>
</div>
)}
</CardContent>
</Card>
{/* Project Versions */}
{selectedProject && versions && (
<Card>
<CardHeader>
<CardTitle>
Versions for "{selectedProject}"
</CardTitle>
</CardHeader>
<CardContent>
{versions.versions.length > 0 ? (
<div className="space-y-2">
{versions.versions.map((version) => (
<div
key={version}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-sm">{version}</span>
</div>
<Badge variant="secondary">
{version === versions.versions[versions.versions.length - 1]
? "Latest"
: ""}
</Badge>
</div>
))}
</div>
) : (
<p className="text-center text-sm text-muted-foreground">
No versions found for this project
</p>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,282 @@
"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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Bug, PlayCircle, AlertCircle } from "lucide-react";
import { ListProjects, ListSpiders, ScheduleJob } from "@/lib/types";
import { Textarea } from "@/components/ui/textarea";
export default function SpidersPage() {
const queryClient = useQueryClient();
const [selectedProject, setSelectedProject] = useState<string>("");
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const [selectedSpider, setSelectedSpider] = useState<string>("");
// 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 spiders for selected project
const { data: spiders, isLoading: isSpidersLoading } = useQuery({
queryKey: ["spiders", selectedProject],
queryFn: async (): Promise<ListSpiders> => {
const res = await fetch(
`/ui/api/scrapyd/spiders?project=${selectedProject}`
);
if (!res.ok) throw new Error("Failed to fetch spiders");
return res.json();
},
enabled: !!selectedProject,
});
// Schedule job mutation
const scheduleJobMutation = useMutation({
mutationFn: async (data: {
project: string;
spider: string;
args?: Record<string, string>;
}) => {
const res = await fetch("/ui/api/scrapyd/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to schedule job");
return res.json() as Promise<ScheduleJob>;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["jobs"] });
setScheduleDialogOpen(false);
setSelectedSpider("");
},
});
const handleSchedule = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const argsStr = formData.get("args") as string;
let args: Record<string, string> | undefined;
if (argsStr.trim()) {
try {
args = JSON.parse(argsStr);
} catch (error) {
alert("Invalid JSON format for arguments");
return;
}
}
scheduleJobMutation.mutate({
project: selectedProject,
spider: selectedSpider,
args,
});
};
return (
<div className="space-y-6">
<Header
title="Spiders"
description="Browse and schedule spider jobs"
/>
{/* Project Selector */}
<Card>
<CardHeader>
<CardTitle>Select 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>
{/* Spiders List */}
{selectedProject && (
<Card>
<CardHeader>
<CardTitle>
Spiders in "{selectedProject}"
</CardTitle>
</CardHeader>
<CardContent>
{isSpidersLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : spiders?.spiders && spiders.spiders.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Spider Name</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{spiders.spiders.map((spider) => (
<TableRow key={spider}>
<TableCell>
<div className="flex items-center gap-2">
<Bug className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{spider}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">Available</Badge>
</TableCell>
<TableCell className="text-right">
<Dialog
open={scheduleDialogOpen && selectedSpider === spider}
onOpenChange={(open) => {
setScheduleDialogOpen(open);
if (open) setSelectedSpider(spider);
}}
>
<DialogTrigger asChild>
<Button
size="sm"
onClick={() => setSelectedSpider(spider)}
>
<PlayCircle className="mr-2 h-4 w-4" />
Schedule
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSchedule}>
<DialogHeader>
<DialogTitle>Schedule Spider Job</DialogTitle>
<DialogDescription>
Schedule "{spider}" to run on "{selectedProject}"
</DialogDescription>
</DialogHeader>
<div className="gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="project-name">Project</Label>
<Input
id="project-name"
value={selectedProject}
disabled
/>
</div>
<div className="space-y-2">
<Label htmlFor="spider-name">Spider</Label>
<Input
id="spider-name"
value={spider}
disabled
/>
</div>
<div className="space-y-2">
<Label htmlFor="args">
Arguments (JSON)
</Label>
<Textarea
id="args"
name="args"
placeholder='{"url": "https://example.com", "pages": 10}'
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Optional: Provide spider arguments in JSON format
</p>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={scheduleJobMutation.isPending}
>
{scheduleJobMutation.isPending
? "Scheduling..."
: "Schedule Job"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</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 spiders found</h3>
<p className="text-sm text-muted-foreground">
This project doesn't have any spiders yet
</p>
</div>
)}
</CardContent>
</Card>
)}
{!selectedProject && !isProjectsLoading && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Bug 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 spiders
</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Header } from "@/components/header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Activity,
Server,
Cpu,
HardDrive,
CheckCircle,
Clock,
PlayCircle,
CheckCircle2,
} from "lucide-react";
import { DaemonStatus, ListProjects } from "@/lib/types";
export default function SystemPage() {
const { data: daemonStatus, isLoading: isDaemonLoading } = useQuery({
queryKey: ["daemon-status"],
queryFn: async (): Promise<DaemonStatus> => {
const res = await fetch("/ui/api/scrapyd/daemon");
if (!res.ok) throw new Error("Failed to fetch daemon status");
return res.json();
},
refetchInterval: 10000, // Refresh every 10 seconds
});
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();
},
});
const systemMetrics = [
{
label: "Daemon Status",
value: daemonStatus?.status || "unknown",
icon: Activity,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
label: "Running Jobs",
value: daemonStatus?.running ?? 0,
icon: PlayCircle,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
label: "Pending Jobs",
value: daemonStatus?.pending ?? 0,
icon: Clock,
color: "text-yellow-500",
bgColor: "bg-yellow-500/10",
},
{
label: "Finished Jobs",
value: daemonStatus?.finished ?? 0,
icon: CheckCircle2,
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
];
return (
<div className="space-y-6">
<Header
title="System Status"
description="Monitor Scrapyd daemon health and metrics"
/>
{/* Status Overview */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Daemon Information</CardTitle>
{isDaemonLoading ? (
<Skeleton className="h-6 w-16" />
) : (
<Badge
variant={daemonStatus?.status === "ok" ? "default" : "destructive"}
className="gap-1"
>
{daemonStatus?.status === "ok" ? (
<CheckCircle className="h-3 w-3" />
) : (
<Activity className="h-3 w-3" />
)}
{daemonStatus?.status?.toUpperCase()}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{isDaemonLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-6 w-full" />
))}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between border-b pb-3">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Node Name</span>
</div>
<code className="rounded bg-muted px-2 py-1 text-sm">
{daemonStatus?.node_name}
</code>
</div>
<div className="flex items-center justify-between border-b pb-3">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Total Projects</span>
</div>
<span className="text-sm font-bold">
{isProjectsLoading ? (
<Skeleton className="h-5 w-8" />
) : (
projects?.projects?.length ?? 0
)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Total Jobs (All Time)</span>
</div>
<span className="text-sm font-bold">
{(daemonStatus?.running ?? 0) +
(daemonStatus?.pending ?? 0) +
(daemonStatus?.finished ?? 0)}
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* System Metrics Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{systemMetrics.map((metric, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between gap-0 pb-2">
<CardTitle className="text-sm font-medium">
{metric.label}
</CardTitle>
<div className={`rounded-full p-2 ${metric.bgColor}`}>
<metric.icon className={`h-4 w-4 ${metric.color}`} />
</div>
</CardHeader>
<CardContent>
{isDaemonLoading ? (
<Skeleton className="h-8 w-20" />
) : (
<div className="text-2xl font-bold">{metric.value}</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Job Queue Status */}
<Card>
<CardHeader>
<CardTitle>Job Queue Status</CardTitle>
</CardHeader>
<CardContent>
{isDaemonLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<div className="rounded-full bg-blue-500/10 p-2">
<PlayCircle className="h-5 w-5 text-blue-500" />
</div>
<div>
<p className="text-sm font-medium">Running Jobs</p>
<p className="text-xs text-muted-foreground">
Currently executing
</p>
</div>
</div>
<span className="text-2xl font-bold">
{daemonStatus?.running}
</span>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<div className="rounded-full bg-yellow-500/10 p-2">
<Clock className="h-5 w-5 text-yellow-500" />
</div>
<div>
<p className="text-sm font-medium">Pending Jobs</p>
<p className="text-xs text-muted-foreground">
Waiting in queue
</p>
</div>
</div>
<span className="text-2xl font-bold">
{daemonStatus?.pending}
</span>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<div className="rounded-full bg-purple-500/10 p-2">
<CheckCircle2 className="h-5 w-5 text-purple-500" />
</div>
<div>
<p className="text-sm font-medium">Finished Jobs</p>
<p className="text-xs text-muted-foreground">
Completed successfully
</p>
</div>
</div>
<span className="text-2xl font-bold">
{daemonStatus?.finished}
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Environment Info */}
<Card>
<CardHeader>
<CardTitle>Environment Information</CardTitle>
</CardHeader>
<CardContent>
<div className="gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Scrapyd URL:</span>
<code className="rounded bg-muted px-2 py-1 text-xs">
{process.env.NEXT_PUBLIC_SCRAPYD_URL || "https://scrapy.pivoine.art"}
</code>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">UI Version:</span>
<Badge variant="outline">1.0.0</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Refresh Interval:</span>
<span>10 seconds</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}