hermes-agent/web/src/pages/CronPage.tsx
kshitijk4poor 96dc272623 fix(cron): use getJobState helper in handlePauseResume
Self-review follow-up: handlePauseResume read job.state directly while
the rest of the page goes through getJobState(), which falls back to
the enabled flag when state is null/undefined. With the backend
normalizer in this PR, state is always populated on the wire, so this
has no observable effect today — but using the helper keeps the page
consistent and resilient against older Hermes backends that don't run
the normalizer.
2026-05-09 01:11:41 -07:00

403 lines
13 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@/components/NouiTypography";
import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@/hooks/useToast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleString();
}
function asText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function truncateText(value: string, maxLength: number): string {
return value.length > maxLength
? value.slice(0, maxLength) + "..."
: value;
}
function getJobPrompt(job: CronJob): string {
return asText(job.prompt);
}
function getJobName(job: CronJob): string {
return asText(job.name).trim();
}
function getJobTitle(job: CronJob): string {
const name = getJobName(job);
if (name) return name;
const prompt = getJobPrompt(job);
if (prompt) return truncateText(prompt, 60);
const script = asText(job.script);
if (script) return truncateText(script, 60);
return job.id || "Cron job";
}
function getJobScheduleDisplay(job: CronJob): string {
return (
asText(job.schedule_display) ||
asText(job.schedule?.display) ||
asText(job.schedule?.expr) ||
"—"
);
}
function getJobState(job: CronJob): string {
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
}
const STATUS_TONE: 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 = useCallback(() => {
api
.getCronJobs()
.then(setJobs)
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, [showToast, t.common.loading]);
useEffect(() => {
loadJobs();
}, [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 = getJobState(job) === "paused";
if (isPaused) {
await api.resumeCronJob(job.id);
showToast(
`${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
} else {
await api.pauseCronJob(job.id);
showToast(
`${t.cron.pause}: "${truncateText(getJobTitle(job), 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}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const jobDelete = useConfirmDelete({
onDelete: useCallback(
async (id: string) => {
const job = jobs.find((j) => j.id === id);
try {
await api.deleteCronJob(id);
showToast(
`${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
throw e;
}
},
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
),
});
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingJob = jobDelete.pendingId
? jobs.find((j) => j.id === jobDelete.pendingId)
: null;
return (
<div className="flex flex-col gap-6">
<PluginSlot name="cron:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
onConfirm={jobDelete.confirm}
title={t.cron.confirmDeleteTitle}
description={
pendingJob
? `"${truncateText(getJobTitle(pendingJob), 40)}" — ${
t.cron.confirmDeleteMessage
}`
: t.cron.confirmDeleteMessage
}
loading={jobDelete.isDeleting}
/>
<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}
prefix={<Plus />}
className="w-full"
>
{creating ? t.common.creating : t.common.create}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<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) => {
const state = getJobState(job);
const promptText = getJobPrompt(job);
const title = getJobTitle(job);
const hasName = Boolean(getJobName(job));
const deliver = asText(job.deliver);
return (
<Card key={job.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{title}
</span>
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
{state}
</Badge>
{deliver && deliver !== "local" && (
<Badge tone="outline">{deliver}</Badge>
)}
</div>
{hasName && promptText && (
<p className="text-xs text-muted-foreground truncate mb-1">
{truncateText(promptText, 100)}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{getJobScheduleDisplay(job)}</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>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title={state === "paused" ? t.cron.resume : t.cron.pause}
aria-label={
state === "paused" ? t.cron.resume : t.cron.pause
}
onClick={() => handlePauseResume(job)}
className={
state === "paused" ? "text-success" : "text-warning"
}
>
{state === "paused" ? <Play /> : <Pause />}
</Button>
<Button
ghost
size="icon"
title={t.cron.triggerNow}
aria-label={t.cron.triggerNow}
onClick={() => handleTrigger(job)}
>
<Zap />
</Button>
<Button
ghost
destructive
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => jobDelete.requestDelete(job.id)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
<PluginSlot name="cron:bottom" />
</div>
);
}