hermes-agent/web/src/pages/CronPage.tsx

282 lines
9.5 KiB
TypeScript

import { useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectOption } from "@/components/ui/select";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleString();
}
const STATUS_VARIANT: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success",
scheduled: "success",
paused: "warning",
error: "destructive",
completed: "destructive",
};
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
// New job form state
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
const [name, setName] = useState("");
const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false);
const loadJobs = () => {
api
.getCronJobs()
.then(setJobs)
.catch(() => showToast("Failed to load cron jobs", "error"))
.finally(() => setLoading(false));
};
useEffect(() => {
loadJobs();
}, []);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
showToast("Prompt and schedule are required", "error");
return;
}
setCreating(true);
try {
await api.createCronJob({
prompt: prompt.trim(),
schedule: schedule.trim(),
name: name.trim() || undefined,
deliver,
});
showToast("Cron job created", "success");
setPrompt("");
setSchedule("");
setName("");
setDeliver("local");
loadJobs();
} catch (e) {
showToast(`Failed to create job: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handlePauseResume = async (job: CronJob) => {
try {
const isPaused = job.state === "paused";
if (isPaused) {
await api.resumeCronJob(job.id);
showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success");
} else {
await api.pauseCronJob(job.id);
showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success");
}
loadJobs();
} catch (e) {
showToast(`Action failed: ${e}`, "error");
}
};
const handleTrigger = async (job: CronJob) => {
try {
await api.triggerCronJob(job.id);
showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success");
loadJobs();
} catch (e) {
showToast(`Trigger failed: ${e}`, "error");
}
};
const handleDelete = async (job: CronJob) => {
try {
await api.deleteCronJob(job.id);
showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success");
loadJobs();
} catch (e) {
showToast(`Delete failed: ${e}`, "error");
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{/* Create new job form */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plus className="h-4 w-4" />
New Cron Job
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-name">Name (optional)</Label>
<Input
id="cron-name"
placeholder="e.g. Daily summary"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-prompt">Prompt</Label>
<textarea
id="cron-prompt"
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="What should the agent do on each run?"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">Schedule (cron expression)</Label>
<Input
id="cron-schedule"
placeholder="0 9 * * *"
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">Deliver to</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={setDeliver}
>
<SelectOption value="local">Local</SelectOption>
<SelectOption value="telegram">Telegram</SelectOption>
<SelectOption value="discord">Discord</SelectOption>
<SelectOption value="slack">Slack</SelectOption>
<SelectOption value="email">Email</SelectOption>
</Select>
</div>
<div className="flex items-end">
<Button onClick={handleCreate} disabled={creating} className="w-full">
<Plus className="h-3 w-3" />
{creating ? "Creating..." : "Create"}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Jobs list */}
<div className="flex flex-col gap-3">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Scheduled Jobs ({jobs.length})
</h2>
{jobs.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No cron jobs configured. Create one above.
</CardContent>
</Card>
)}
{jobs.map((job) => (
<Card key={job.id}>
<CardContent className="flex items-center gap-4 py-4">
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{job.name || job.prompt.slice(0, 60) + (job.prompt.length > 60 ? "..." : "")}
</span>
<Badge variant={STATUS_VARIANT[job.state] ?? "secondary"}>
{job.state}
</Badge>
{job.deliver && job.deliver !== "local" && (
<Badge variant="outline">{job.deliver}</Badge>
)}
</div>
{job.name && (
<p className="text-xs text-muted-foreground truncate mb-1">
{job.prompt.slice(0, 100)}{job.prompt.length > 100 ? "..." : ""}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule_display}</span>
<span>Last: {formatTime(job.last_run_at)}</span>
<span>Next: {formatTime(job.next_run_at)}</span>
</div>
{job.last_error && (
<p className="text-xs text-destructive mt-1">{job.last_error}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
title={job.state === "paused" ? "Resume" : "Pause"}
aria-label={job.state === "paused" ? "Resume job" : "Pause job"}
onClick={() => handlePauseResume(job)}
>
{job.state === "paused" ? (
<Play className="h-4 w-4 text-success" />
) : (
<Pause className="h-4 w-4 text-warning" />
)}
</Button>
<Button
variant="ghost"
size="icon"
title="Trigger now"
aria-label="Trigger job now"
onClick={() => handleTrigger(job)}
>
<Zap className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete"
aria-label="Delete job"
onClick={() => handleDelete(job)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}