/ web / src / pages / CronPage.tsx
CronPage.tsx
  1  import { useCallback, useEffect, useState } from "react";
  2  import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
  3  import { Badge } from "@nous-research/ui/ui/components/badge";
  4  import { Button } from "@nous-research/ui/ui/components/button";
  5  import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
  6  import { Spinner } from "@nous-research/ui/ui/components/spinner";
  7  import { H2 } from "@/components/NouiTypography";
  8  import { api } from "@/lib/api";
  9  import type { CronJob } from "@/lib/api";
 10  import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
 11  import { useToast } from "@/hooks/useToast";
 12  import { useConfirmDelete } from "@/hooks/useConfirmDelete";
 13  import { Toast } from "@/components/Toast";
 14  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 15  import { Input } from "@/components/ui/input";
 16  import { Label } from "@/components/ui/label";
 17  import { useI18n } from "@/i18n";
 18  import { PluginSlot } from "@/plugins";
 19  
 20  function formatTime(iso?: string | null): string {
 21    if (!iso) return "—";
 22    const d = new Date(iso);
 23    return d.toLocaleString();
 24  }
 25  
 26  const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
 27    enabled: "success",
 28    scheduled: "success",
 29    paused: "warning",
 30    error: "destructive",
 31    completed: "destructive",
 32  };
 33  
 34  export default function CronPage() {
 35    const [jobs, setJobs] = useState<CronJob[]>([]);
 36    const [loading, setLoading] = useState(true);
 37    const { toast, showToast } = useToast();
 38    const { t } = useI18n();
 39  
 40    // New job form state
 41    const [prompt, setPrompt] = useState("");
 42    const [schedule, setSchedule] = useState("");
 43    const [name, setName] = useState("");
 44    const [deliver, setDeliver] = useState("local");
 45    const [creating, setCreating] = useState(false);
 46  
 47    const loadJobs = useCallback(() => {
 48      api
 49        .getCronJobs()
 50        .then(setJobs)
 51        .catch(() => showToast(t.common.loading, "error"))
 52        .finally(() => setLoading(false));
 53    }, [showToast, t.common.loading]);
 54  
 55    useEffect(() => {
 56      loadJobs();
 57    }, [loadJobs]);
 58  
 59    const handleCreate = async () => {
 60      if (!prompt.trim() || !schedule.trim()) {
 61        showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
 62        return;
 63      }
 64      setCreating(true);
 65      try {
 66        await api.createCronJob({
 67          prompt: prompt.trim(),
 68          schedule: schedule.trim(),
 69          name: name.trim() || undefined,
 70          deliver,
 71        });
 72        showToast(t.common.create + " ✓", "success");
 73        setPrompt("");
 74        setSchedule("");
 75        setName("");
 76        setDeliver("local");
 77        loadJobs();
 78      } catch (e) {
 79        showToast(`${t.config.failedToSave}: ${e}`, "error");
 80      } finally {
 81        setCreating(false);
 82      }
 83    };
 84  
 85    const handlePauseResume = async (job: CronJob) => {
 86      try {
 87        const isPaused = job.state === "paused";
 88        if (isPaused) {
 89          await api.resumeCronJob(job.id);
 90          showToast(
 91            `${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`,
 92            "success",
 93          );
 94        } else {
 95          await api.pauseCronJob(job.id);
 96          showToast(
 97            `${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`,
 98            "success",
 99          );
100        }
101        loadJobs();
102      } catch (e) {
103        showToast(`${t.status.error}: ${e}`, "error");
104      }
105    };
106  
107    const handleTrigger = async (job: CronJob) => {
108      try {
109        await api.triggerCronJob(job.id);
110        showToast(
111          `${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`,
112          "success",
113        );
114        loadJobs();
115      } catch (e) {
116        showToast(`${t.status.error}: ${e}`, "error");
117      }
118    };
119  
120    const jobDelete = useConfirmDelete({
121      onDelete: useCallback(
122        async (id: string) => {
123          const job = jobs.find((j) => j.id === id);
124          try {
125            await api.deleteCronJob(id);
126            showToast(
127              `${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`,
128              "success",
129            );
130            loadJobs();
131          } catch (e) {
132            showToast(`${t.status.error}: ${e}`, "error");
133            throw e;
134          }
135        },
136        [jobs, loadJobs, showToast, t.common.delete, t.status.error],
137      ),
138    });
139  
140    if (loading) {
141      return (
142        <div className="flex items-center justify-center py-24">
143          <Spinner className="text-2xl text-primary" />
144        </div>
145      );
146    }
147  
148    const pendingJob = jobDelete.pendingId
149      ? jobs.find((j) => j.id === jobDelete.pendingId)
150      : null;
151  
152    return (
153      <div className="flex flex-col gap-6">
154        <PluginSlot name="cron:top" />
155        <Toast toast={toast} />
156  
157        <DeleteConfirmDialog
158          open={jobDelete.isOpen}
159          onCancel={jobDelete.cancel}
160          onConfirm={jobDelete.confirm}
161          title={t.cron.confirmDeleteTitle}
162          description={
163            pendingJob
164              ? `"${pendingJob.name || pendingJob.prompt.slice(0, 40)}" — ${t.cron.confirmDeleteMessage}`
165              : t.cron.confirmDeleteMessage
166          }
167          loading={jobDelete.isDeleting}
168        />
169  
170        <Card>
171          <CardHeader>
172            <CardTitle className="flex items-center gap-2 text-base">
173              <Plus className="h-4 w-4" />
174              {t.cron.newJob}
175            </CardTitle>
176          </CardHeader>
177          <CardContent>
178            <div className="grid gap-4">
179              <div className="grid gap-2">
180                <Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
181                <Input
182                  id="cron-name"
183                  placeholder={t.cron.namePlaceholder}
184                  value={name}
185                  onChange={(e) => setName(e.target.value)}
186                />
187              </div>
188  
189              <div className="grid gap-2">
190                <Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
191                <textarea
192                  id="cron-prompt"
193                  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"
194                  placeholder={t.cron.promptPlaceholder}
195                  value={prompt}
196                  onChange={(e) => setPrompt(e.target.value)}
197                />
198              </div>
199  
200              <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
201                <div className="grid gap-2">
202                  <Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
203                  <Input
204                    id="cron-schedule"
205                    placeholder={t.cron.schedulePlaceholder}
206                    value={schedule}
207                    onChange={(e) => setSchedule(e.target.value)}
208                  />
209                </div>
210  
211                <div className="grid gap-2">
212                  <Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
213                  <Select
214                    id="cron-deliver"
215                    value={deliver}
216                    onValueChange={(v) => setDeliver(v)}
217                  >
218                    <SelectOption value="local">
219                      {t.cron.delivery.local}
220                    </SelectOption>
221                    <SelectOption value="telegram">
222                      {t.cron.delivery.telegram}
223                    </SelectOption>
224                    <SelectOption value="discord">
225                      {t.cron.delivery.discord}
226                    </SelectOption>
227                    <SelectOption value="slack">
228                      {t.cron.delivery.slack}
229                    </SelectOption>
230                    <SelectOption value="email">
231                      {t.cron.delivery.email}
232                    </SelectOption>
233                  </Select>
234                </div>
235  
236                <div className="flex items-end">
237                  <Button
238                    onClick={handleCreate}
239                    disabled={creating}
240                    prefix={<Plus />}
241                    className="w-full"
242                  >
243                    {creating ? t.common.creating : t.common.create}
244                  </Button>
245                </div>
246              </div>
247            </div>
248          </CardContent>
249        </Card>
250  
251        <div className="flex flex-col gap-3">
252          <H2
253            variant="sm"
254            className="flex items-center gap-2 text-muted-foreground"
255          >
256            <Clock className="h-4 w-4" />
257            {t.cron.scheduledJobs} ({jobs.length})
258          </H2>
259  
260          {jobs.length === 0 && (
261            <Card>
262              <CardContent className="py-8 text-center text-sm text-muted-foreground">
263                {t.cron.noJobs}
264              </CardContent>
265            </Card>
266          )}
267  
268          {jobs.map((job) => (
269            <Card key={job.id}>
270              <CardContent className="flex items-center gap-4 py-4">
271                <div className="flex-1 min-w-0">
272                  <div className="flex items-center gap-2 mb-1">
273                    <span className="font-medium text-sm truncate">
274                      {job.name ||
275                        job.prompt.slice(0, 60) +
276                          (job.prompt.length > 60 ? "..." : "")}
277                    </span>
278                    <Badge tone={STATUS_TONE[job.state] ?? "secondary"}>
279                      {job.state}
280                    </Badge>
281                    {job.deliver && job.deliver !== "local" && (
282                      <Badge tone="outline">{job.deliver}</Badge>
283                    )}
284                  </div>
285                  {job.name && (
286                    <p className="text-xs text-muted-foreground truncate mb-1">
287                      {job.prompt.slice(0, 100)}
288                      {job.prompt.length > 100 ? "..." : ""}
289                    </p>
290                  )}
291                  <div className="flex items-center gap-4 text-xs text-muted-foreground">
292                    <span className="font-mono">{job.schedule_display}</span>
293                    <span>
294                      {t.cron.last}: {formatTime(job.last_run_at)}
295                    </span>
296                    <span>
297                      {t.cron.next}: {formatTime(job.next_run_at)}
298                    </span>
299                  </div>
300                  {job.last_error && (
301                    <p className="text-xs text-destructive mt-1">
302                      {job.last_error}
303                    </p>
304                  )}
305                </div>
306  
307                <div className="flex items-center gap-1 shrink-0">
308                  <Button
309                    ghost
310                    size="icon"
311                    title={job.state === "paused" ? t.cron.resume : t.cron.pause}
312                    aria-label={
313                      job.state === "paused" ? t.cron.resume : t.cron.pause
314                    }
315                    onClick={() => handlePauseResume(job)}
316                    className={
317                      job.state === "paused" ? "text-success" : "text-warning"
318                    }
319                  >
320                    {job.state === "paused" ? <Play /> : <Pause />}
321                  </Button>
322  
323                  <Button
324                    ghost
325                    size="icon"
326                    title={t.cron.triggerNow}
327                    aria-label={t.cron.triggerNow}
328                    onClick={() => handleTrigger(job)}
329                  >
330                    <Zap />
331                  </Button>
332  
333                  <Button
334                    ghost
335                    destructive
336                    size="icon"
337                    title={t.common.delete}
338                    aria-label={t.common.delete}
339                    onClick={() => jobDelete.requestDelete(job.id)}
340                  >
341                    <Trash2 />
342                  </Button>
343                </div>
344              </CardContent>
345            </Card>
346          ))}
347        </div>
348  
349        <PluginSlot name="cron:bottom" />
350      </div>
351    );
352  }