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 }