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>
|
||||
);
|
||||
}
|
||||
19
app/(dashboard)/layout.tsx
Normal file
19
app/(dashboard)/layout.tsx
Normal 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
173
app/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
326
app/(dashboard)/projects/page.tsx
Normal file
326
app/(dashboard)/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
282
app/(dashboard)/spiders/page.tsx
Normal file
282
app/(dashboard)/spiders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
app/(dashboard)/system/page.tsx
Normal file
267
app/(dashboard)/system/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
app/api/scrapyd/daemon/route.ts
Normal file
15
app/api/scrapyd/daemon/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ScrapydClient } from "@/lib/scrapyd-client";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await ScrapydClient.getDaemonStatus();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch daemon status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch daemon status" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
app/api/scrapyd/jobs/route.ts
Normal file
77
app/api/scrapyd/jobs/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ScrapydClient } from "@/lib/scrapyd-client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const project = searchParams.get("project");
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await ScrapydClient.listJobs({ project });
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch jobs:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch jobs" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { project, spider, jobid, settings, args } = body;
|
||||
|
||||
if (!project || !spider) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project and spider are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await ScrapydClient.scheduleJob({
|
||||
project,
|
||||
spider,
|
||||
jobid,
|
||||
settings,
|
||||
args,
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to schedule job:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to schedule job" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { project, job } = body;
|
||||
|
||||
if (!project || !job) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project and job ID are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await ScrapydClient.cancelJob({ project, job });
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel job:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to cancel job" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/api/scrapyd/projects/route.ts
Normal file
38
app/api/scrapyd/projects/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ScrapydClient } from "@/lib/scrapyd-client";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await ScrapydClient.listProjects();
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch projects:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch projects" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { project } = body;
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await ScrapydClient.deleteProject({ project });
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete project" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
26
app/api/scrapyd/spiders/route.ts
Normal file
26
app/api/scrapyd/spiders/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ScrapydClient } from "@/lib/scrapyd-client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const project = searchParams.get("project");
|
||||
const version = searchParams.get("version") || undefined;
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await ScrapydClient.listSpiders({ project, version });
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch spiders:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch spiders" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/api/scrapyd/versions/route.ts
Normal file
74
app/api/scrapyd/versions/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ScrapydClient } from "@/lib/scrapyd-client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const project = searchParams.get("project");
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await ScrapydClient.listVersions({ project });
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch versions:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch versions" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { project, version } = body;
|
||||
|
||||
if (!project || !version) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project and version are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await ScrapydClient.deleteVersion({ project, version });
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete version:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete version" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const project = formData.get("project") as string;
|
||||
const version = formData.get("version") as string;
|
||||
const eggFile = formData.get("egg") as File;
|
||||
|
||||
if (!project || !version || !eggFile) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project, version, and egg file are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await eggFile.arrayBuffer());
|
||||
const data = await ScrapydClient.addVersion(project, version, buffer);
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to add version:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to add version" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
110
app/globals.css
Normal file
110
app/globals.css
Normal file
@@ -0,0 +1,110 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../components/*.{js,ts,jsx,tsx}";
|
||||
@source "../components/ui/*.{js,ts,jsx,tsx}";
|
||||
@source "./(dashboard)/*.{js,ts,jsx,tsx}";
|
||||
@source "./(dashboard)/jobs/*.{js,ts,jsx,tsx}";
|
||||
@source "./(dashboard)/projects/*.{js,ts,jsx,tsx}";
|
||||
@source "./(dashboard)/spiders/*.{js,ts,jsx,tsx}";
|
||||
@source "./(dashboard)/system/*.{js,ts,jsx,tsx}";
|
||||
@source "*.{js,ts,jsx,tsx}";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Light Mode Colors - Using OKLCH for better color precision */
|
||||
--background: oklch(100% 0 0);
|
||||
--foreground: oklch(9.8% 0 0);
|
||||
--card: oklch(100% 0 0);
|
||||
--card-foreground: oklch(9.8% 0 0);
|
||||
--popover: oklch(100% 0 0);
|
||||
--popover-foreground: oklch(9.8% 0 0);
|
||||
--primary: oklch(18% 0 0);
|
||||
--primary-foreground: oklch(98% 0 0);
|
||||
--secondary: oklch(96.1% 0 0);
|
||||
--secondary-foreground: oklch(18% 0 0);
|
||||
--muted: oklch(96.1% 0 0);
|
||||
--muted-foreground: oklch(55% 0 0);
|
||||
--accent: oklch(96.1% 0 0);
|
||||
--accent-foreground: oklch(18% 0 0);
|
||||
--destructive: oklch(62.8% 0.257 29.234);
|
||||
--destructive-foreground: oklch(98% 0 0);
|
||||
--border: oklch(89.8% 0 0);
|
||||
--input: oklch(89.8% 0 0);
|
||||
--ring: oklch(9.8% 0 0);
|
||||
|
||||
/* Chart Colors */
|
||||
--chart-1: oklch(68% 0.14 29);
|
||||
--chart-2: oklch(55% 0.14 192);
|
||||
--chart-3: oklch(42% 0.10 218);
|
||||
--chart-4: oklch(72% 0.15 84);
|
||||
--chart-5: oklch(70% 0.18 41);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Tailwind v4 theme color definitions */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(15% 0 0);
|
||||
--foreground: oklch(98% 0 0);
|
||||
--card: oklch(15% 0 0);
|
||||
--card-foreground: oklch(98% 0 0);
|
||||
--popover: oklch(15% 0 0);
|
||||
--popover-foreground: oklch(98% 0 0);
|
||||
--primary: oklch(98% 0 0);
|
||||
--primary-foreground: oklch(18% 0 0);
|
||||
--secondary: oklch(25% 0 0);
|
||||
--secondary-foreground: oklch(98% 0 0);
|
||||
--muted: oklch(25% 0 0);
|
||||
--muted-foreground: oklch(65% 0 0);
|
||||
--accent: oklch(25% 0 0);
|
||||
--accent-foreground: oklch(98% 0 0);
|
||||
--destructive: oklch(45% 0.15 29);
|
||||
--destructive-foreground: oklch(98% 0 0);
|
||||
--border: oklch(25% 0 0);
|
||||
--input: oklch(25% 0 0);
|
||||
--ring: oklch(83% 0 0);
|
||||
|
||||
/* Chart Colors Dark */
|
||||
--chart-1: oklch(60% 0.14 250);
|
||||
--chart-2: oklch(55% 0.12 180);
|
||||
--chart-3: oklch(65% 0.15 70);
|
||||
--chart-4: oklch(68% 0.13 310);
|
||||
--chart-5: oklch(66% 0.14 10);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
42
app/layout.tsx
Normal file
42
app/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Scrapy UI - Scrapyd Management Interface",
|
||||
description: "Manage and monitor your Scrapyd scraping projects",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user