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

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

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

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

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

110
app/globals.css Normal file
View 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
View 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>
);
}