mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Re-applies changes from #9471 that were overwritten by the i18n PR: - URL-based routing via react-router-dom (NavLink, Routes, BrowserRouter) - Replace emoji icons with lucide-react in ConfigPage and SkillsPage - Sidebar layout for ConfigPage, SkillsPage, and LogsPage - Custom dropdown Select component (SelectOption) in CronPage - Remove all non-functional rounded borders across the UI - Fixed header with proper content offset Made-with: Cursor
284 lines
9.8 KiB
TypeScript
284 lines
9.8 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";
|
|
import { useI18n } from "@/i18n";
|
|
|
|
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();
|
|
const { t } = useI18n();
|
|
|
|
// 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(t.common.loading, "error"))
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadJobs();
|
|
}, []);
|
|
|
|
const handleCreate = async () => {
|
|
if (!prompt.trim() || !schedule.trim()) {
|
|
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
try {
|
|
await api.createCronJob({
|
|
prompt: prompt.trim(),
|
|
schedule: schedule.trim(),
|
|
name: name.trim() || undefined,
|
|
deliver,
|
|
});
|
|
showToast(t.common.create + " ✓", "success");
|
|
setPrompt("");
|
|
setSchedule("");
|
|
setName("");
|
|
setDeliver("local");
|
|
loadJobs();
|
|
} catch (e) {
|
|
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handlePauseResume = async (job: CronJob) => {
|
|
try {
|
|
const isPaused = job.state === "paused";
|
|
if (isPaused) {
|
|
await api.resumeCronJob(job.id);
|
|
showToast(`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
|
} else {
|
|
await api.pauseCronJob(job.id);
|
|
showToast(`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
|
}
|
|
loadJobs();
|
|
} catch (e) {
|
|
showToast(`${t.status.error}: ${e}`, "error");
|
|
}
|
|
};
|
|
|
|
const handleTrigger = async (job: CronJob) => {
|
|
try {
|
|
await api.triggerCronJob(job.id);
|
|
showToast(`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
|
loadJobs();
|
|
} catch (e) {
|
|
showToast(`${t.status.error}: ${e}`, "error");
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (job: CronJob) => {
|
|
try {
|
|
await api.deleteCronJob(job.id);
|
|
showToast(`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
|
loadJobs();
|
|
} catch (e) {
|
|
showToast(`${t.status.error}: ${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" />
|
|
{t.cron.newJob}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
|
<Input
|
|
id="cron-name"
|
|
placeholder={t.cron.namePlaceholder}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cron-prompt">{t.cron.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={t.cron.promptPlaceholder}
|
|
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">{t.cron.schedule}</Label>
|
|
<Input
|
|
id="cron-schedule"
|
|
placeholder={t.cron.schedulePlaceholder}
|
|
value={schedule}
|
|
onChange={(e) => setSchedule(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
|
<Select
|
|
id="cron-deliver"
|
|
value={deliver}
|
|
onValueChange={(v) => setDeliver(v)}
|
|
>
|
|
<SelectOption value="local">{t.cron.delivery.local}</SelectOption>
|
|
<SelectOption value="telegram">{t.cron.delivery.telegram}</SelectOption>
|
|
<SelectOption value="discord">{t.cron.delivery.discord}</SelectOption>
|
|
<SelectOption value="slack">{t.cron.delivery.slack}</SelectOption>
|
|
<SelectOption value="email">{t.cron.delivery.email}</SelectOption>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-end">
|
|
<Button onClick={handleCreate} disabled={creating} className="w-full">
|
|
<Plus className="h-3 w-3" />
|
|
{creating ? t.common.creating : t.common.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" />
|
|
{t.cron.scheduledJobs} ({jobs.length})
|
|
</h2>
|
|
|
|
{jobs.length === 0 && (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
{t.cron.noJobs}
|
|
</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>{t.cron.last}: {formatTime(job.last_run_at)}</span>
|
|
<span>{t.cron.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" ? t.cron.resume : t.cron.pause}
|
|
aria-label={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
|
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={t.cron.triggerNow}
|
|
aria-label={t.cron.triggerNow}
|
|
onClick={() => handleTrigger(job)}
|
|
>
|
|
<Zap className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title={t.common.delete}
|
|
aria-label={t.common.delete}
|
|
onClick={() => handleDelete(job)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|