@gravito/zenith 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/ARCHITECTURE.md +88 -0
  2. package/BATCH_OPERATIONS_IMPLEMENTATION.md +159 -0
  3. package/DEMO.md +156 -0
  4. package/DEPLOYMENT.md +157 -0
  5. package/DOCS_INTERNAL.md +73 -0
  6. package/Dockerfile +46 -0
  7. package/Dockerfile.demo-worker +29 -0
  8. package/EVOLUTION_BLUEPRINT.md +112 -0
  9. package/JOBINSPECTOR_SCROLL_FIX.md +152 -0
  10. package/PULSE_IMPLEMENTATION_PLAN.md +111 -0
  11. package/QUICK_TEST_GUIDE.md +72 -0
  12. package/README.md +33 -0
  13. package/ROADMAP.md +85 -0
  14. package/TESTING_BATCH_OPERATIONS.md +252 -0
  15. package/bin/flux-console.ts +2 -0
  16. package/dist/bin.js +108196 -0
  17. package/dist/client/assets/index-DGYEwTDL.css +1 -0
  18. package/dist/client/assets/index-oyTdySX0.js +421 -0
  19. package/dist/client/index.html +13 -0
  20. package/dist/server/index.js +108191 -0
  21. package/docker-compose.yml +40 -0
  22. package/docs/integrations/LARAVEL.md +207 -0
  23. package/package.json +50 -0
  24. package/postcss.config.js +6 -0
  25. package/scripts/flood-logs.ts +21 -0
  26. package/scripts/seed.ts +213 -0
  27. package/scripts/verify-throttle.ts +45 -0
  28. package/scripts/worker.ts +123 -0
  29. package/src/bin.ts +6 -0
  30. package/src/client/App.tsx +70 -0
  31. package/src/client/Layout.tsx +644 -0
  32. package/src/client/Sidebar.tsx +102 -0
  33. package/src/client/ThroughputChart.tsx +135 -0
  34. package/src/client/WorkerStatus.tsx +170 -0
  35. package/src/client/components/ConfirmDialog.tsx +103 -0
  36. package/src/client/components/JobInspector.tsx +524 -0
  37. package/src/client/components/LogArchiveModal.tsx +383 -0
  38. package/src/client/components/NotificationBell.tsx +203 -0
  39. package/src/client/components/Toaster.tsx +80 -0
  40. package/src/client/components/UserProfileDropdown.tsx +177 -0
  41. package/src/client/contexts/AuthContext.tsx +93 -0
  42. package/src/client/contexts/NotificationContext.tsx +103 -0
  43. package/src/client/index.css +174 -0
  44. package/src/client/index.html +12 -0
  45. package/src/client/main.tsx +15 -0
  46. package/src/client/pages/LoginPage.tsx +153 -0
  47. package/src/client/pages/MetricsPage.tsx +408 -0
  48. package/src/client/pages/OverviewPage.tsx +511 -0
  49. package/src/client/pages/QueuesPage.tsx +372 -0
  50. package/src/client/pages/SchedulesPage.tsx +531 -0
  51. package/src/client/pages/SettingsPage.tsx +449 -0
  52. package/src/client/pages/WorkersPage.tsx +316 -0
  53. package/src/client/pages/index.ts +7 -0
  54. package/src/client/utils.ts +6 -0
  55. package/src/server/index.ts +556 -0
  56. package/src/server/middleware/auth.ts +127 -0
  57. package/src/server/services/AlertService.ts +160 -0
  58. package/src/server/services/QueueService.ts +828 -0
  59. package/tailwind.config.js +73 -0
  60. package/tests/placeholder.test.ts +7 -0
  61. package/tsconfig.json +38 -0
  62. package/tsconfig.node.json +12 -0
  63. package/vite.config.ts +27 -0
@@ -0,0 +1,531 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { format } from 'date-fns'
3
+ import { AnimatePresence, motion } from 'framer-motion'
4
+ import {
5
+ Activity,
6
+ AlertCircle,
7
+ ArrowRight,
8
+ Calendar,
9
+ CheckCircle2,
10
+ ChevronDown,
11
+ Clock,
12
+ Filter,
13
+ Play,
14
+ Plus,
15
+ RefreshCcw,
16
+ Search,
17
+ Trash2,
18
+ X,
19
+ } from 'lucide-react'
20
+ import { useState } from 'react'
21
+ import { ConfirmDialog } from '../components/ConfirmDialog'
22
+ import { useNotifications } from '../contexts/NotificationContext'
23
+
24
+ // Update interface to match actual API response
25
+ interface ScheduleInfo {
26
+ id: string
27
+ cron: string
28
+ queue: string
29
+ job: {
30
+ className?: string
31
+ name?: string
32
+ data?: any
33
+ payload?: any
34
+ }
35
+ lastRun?: number | string
36
+ nextRun?: number | string
37
+ }
38
+
39
+ interface QueueListItem {
40
+ name: string
41
+ waiting: number
42
+ delayed: number
43
+ failed: number
44
+ active: number
45
+ paused: boolean
46
+ }
47
+
48
+ export function SchedulesPage() {
49
+ const queryClient = useQueryClient()
50
+ const [isModalOpen, setIsModalOpen] = useState(false)
51
+ const [formData, setFormData] = useState({
52
+ id: '',
53
+ cron: '',
54
+ queue: 'default',
55
+ className: '',
56
+ payload: '{}',
57
+ })
58
+
59
+ const [confirmRunId, setConfirmRunId] = useState<string | null>(null)
60
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
61
+
62
+ const { addNotification } = useNotifications()
63
+
64
+ const { data: queueData } = useQuery<{ queues: QueueListItem[] }>({
65
+ queryKey: ['queues'],
66
+ queryFn: () => fetch('/api/queues').then((res) => res.json()),
67
+ })
68
+
69
+ const { data, isLoading } = useQuery<{ schedules: ScheduleInfo[] }>({
70
+ queryKey: ['schedules'],
71
+ queryFn: () => fetch('/api/schedules').then((res) => res.json()),
72
+ })
73
+
74
+ const runMutation = useMutation({
75
+ mutationFn: (id: string) => fetch(`/api/schedules/run/${id}`, { method: 'POST' }),
76
+ onSuccess: (_, id) => {
77
+ queryClient.invalidateQueries({ queryKey: ['queues'] })
78
+ addNotification({
79
+ type: 'success',
80
+ title: 'Schedule Triggered',
81
+ message: `Job ${id} has been manually pushed to the queue.`,
82
+ })
83
+ setConfirmRunId(null)
84
+ },
85
+ onError: (err: any) => {
86
+ addNotification({
87
+ type: 'error',
88
+ title: 'Trigger Failed',
89
+ message: err.message || 'Failed to run schedule manually.',
90
+ })
91
+ setConfirmRunId(null)
92
+ },
93
+ })
94
+
95
+ const deleteMutation = useMutation({
96
+ mutationFn: (id: string) => fetch(`/api/schedules/${id}`, { method: 'DELETE' }),
97
+ onSuccess: (_, id) => {
98
+ queryClient.invalidateQueries({ queryKey: ['schedules'] })
99
+ addNotification({
100
+ type: 'info',
101
+ title: 'Schedule Deleted',
102
+ message: `Schedule ${id} was removed.`,
103
+ })
104
+ setConfirmDeleteId(null)
105
+ },
106
+ })
107
+
108
+ const registerMutation = useMutation({
109
+ mutationFn: (body: any) =>
110
+ fetch('/api/schedules', {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify(body),
114
+ }),
115
+ onSuccess: () => {
116
+ queryClient.invalidateQueries({ queryKey: ['schedules'] })
117
+ setIsModalOpen(false)
118
+ setFormData({ id: '', cron: '', queue: 'default', className: '', payload: '{}' })
119
+ addNotification({
120
+ type: 'success',
121
+ title: 'Schedule Registered',
122
+ message: 'New recurring task is now active.',
123
+ })
124
+ },
125
+ })
126
+
127
+ const schedules = data?.schedules || []
128
+ const queues = queueData?.queues || []
129
+
130
+ const handleSubmit = (e: React.FormEvent) => {
131
+ e.preventDefault()
132
+ try {
133
+ const payload = JSON.parse(formData.payload)
134
+ registerMutation.mutate({
135
+ id: formData.id,
136
+ cron: formData.cron,
137
+ queue: formData.queue,
138
+ job: {
139
+ className: formData.className,
140
+ payload,
141
+ },
142
+ })
143
+ } catch (_err) {
144
+ alert('Invalid JSON payload')
145
+ }
146
+ }
147
+
148
+ return (
149
+ <div className="space-y-8 max-w-6xl">
150
+ {/* Header */}
151
+ <div className="flex justify-between items-end">
152
+ <div>
153
+ <h1 className="text-4xl font-black tracking-tighter">Schedules</h1>
154
+ <p className="text-muted-foreground mt-2 text-sm font-bold opacity-60 uppercase tracking-widest text-[10px]">
155
+ Manage recurring tasks and cron workflows.
156
+ </p>
157
+ </div>
158
+ <button
159
+ type="button"
160
+ onClick={() => setIsModalOpen(true)}
161
+ className="px-6 py-3 bg-primary text-primary-foreground rounded-xl flex items-center gap-2 text-sm font-black uppercase tracking-widest shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all"
162
+ >
163
+ <Plus size={18} />
164
+ New Schedule
165
+ </button>
166
+ </div>
167
+
168
+ {/* Stats Overview */}
169
+ <div className="grid grid-cols-3 gap-6">
170
+ <div className="card-premium p-6 flex items-center gap-4">
171
+ <div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary">
172
+ <Activity size={24} />
173
+ </div>
174
+ <div>
175
+ <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">
176
+ Active Schedules
177
+ </p>
178
+ <p className="text-2xl font-black">{isLoading ? '...' : schedules.length}</p>
179
+ </div>
180
+ </div>
181
+ <div className="card-premium p-6 flex items-center gap-4">
182
+ <div className="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
183
+ <Calendar size={24} />
184
+ </div>
185
+ <div>
186
+ <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">
187
+ Total Executions
188
+ </p>
189
+ <p className="text-2xl font-black">---</p>
190
+ </div>
191
+ </div>
192
+ <div className="card-premium p-6 flex items-center gap-4">
193
+ <div className="w-12 h-12 rounded-2xl bg-green-500/10 flex items-center justify-center text-green-500">
194
+ <CheckCircle2 size={24} />
195
+ </div>
196
+ <div>
197
+ <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">
198
+ Health Score
199
+ </p>
200
+ <p className="text-2xl font-black text-green-500">99.8%</p>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ {/* Toolbar */}
206
+ <div className="flex gap-4 items-center">
207
+ <div className="relative flex-1">
208
+ <Search
209
+ className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground/50"
210
+ size={18}
211
+ />
212
+ <input
213
+ type="text"
214
+ placeholder="Search schedules by name, cron or ID..."
215
+ className="w-full bg-muted/40 border-border/50 rounded-xl pl-12 pr-4 py-3 text-sm focus:ring-1 focus:ring-primary/30 outline-none transition-all"
216
+ />
217
+ </div>
218
+ <button
219
+ type="button"
220
+ className="p-3 bg-muted/40 border border-border/50 rounded-xl hover:bg-muted/60 transition-all"
221
+ >
222
+ <Filter size={18} />
223
+ </button>
224
+ </div>
225
+
226
+ {/* Loading State */}
227
+ {isLoading && (
228
+ <div className="space-y-4">
229
+ {[1, 2, 3].map((i) => (
230
+ <div key={i} className="card-premium p-6 h-32 animate-pulse bg-muted/20" />
231
+ ))}
232
+ </div>
233
+ )}
234
+
235
+ {/* Implementation Empty State if zero */}
236
+ {schedules.length === 0 && !isLoading && (
237
+ <div className="card-premium p-20 flex flex-col items-center justify-center text-center opacity-60">
238
+ <div className="w-20 h-20 rounded-3xl bg-muted flex items-center justify-center mb-6">
239
+ <Clock size={32} className="text-muted-foreground/30" />
240
+ </div>
241
+ <h3 className="text-xl font-bold mb-2">No Scheduled Tasks Yet</h3>
242
+ <p className="text-sm text-muted-foreground max-w-sm mb-8">
243
+ Register recurring jobs using the @gravito/stream scheduler to see them appear here.
244
+ </p>
245
+ </div>
246
+ )}
247
+
248
+ {/* Schedules Grid */}
249
+ <div className="grid grid-cols-1 gap-4">
250
+ {schedules.map((schedule: ScheduleInfo) => (
251
+ <div
252
+ key={schedule.id}
253
+ className="card-premium p-6 group hover:border-primary/30 transition-all"
254
+ >
255
+ <div className="flex items-start justify-between">
256
+ <div className="flex gap-5">
257
+ <div className="w-14 h-14 rounded-2xl bg-muted flex items-center justify-center text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary transition-all">
258
+ <Clock size={28} />
259
+ </div>
260
+ <div className="space-y-1">
261
+ <div className="flex items-center gap-3">
262
+ <h3 className="text-lg font-bold">{schedule.id}</h3>
263
+ <span className="px-2 py-0.5 bg-green-500/10 text-green-500 text-[10px] font-black uppercase tracking-widest rounded">
264
+ Enabled
265
+ </span>
266
+ </div>
267
+ <div className="flex items-center gap-4 text-xs font-bold text-muted-foreground">
268
+ <span className="flex items-center gap-1.5">
269
+ <Clock size={12} /> {schedule.cron}
270
+ </span>
271
+ <span className="flex items-center gap-1.5">
272
+ <ArrowRight size={12} /> {schedule.queue}
273
+ </span>
274
+ <span className="px-2 py-0.5 bg-muted rounded font-mono text-[10px] uppercase font-black">
275
+ {schedule.job.className}
276
+ </span>
277
+ </div>
278
+ </div>
279
+ </div>
280
+
281
+ <div className="flex items-center gap-2">
282
+ <button
283
+ type="button"
284
+ disabled={runMutation.isPending}
285
+ onClick={() => setConfirmRunId(schedule.id)}
286
+ className="px-4 py-2 bg-muted hover:bg-primary hover:text-primary-foreground rounded-lg text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 flex items-center gap-2 disabled:opacity-50"
287
+ >
288
+ {runMutation.isPending && runMutation.variables === schedule.id ? (
289
+ <>
290
+ <RefreshCcw size={12} className="animate-spin" />
291
+ Running...
292
+ </>
293
+ ) : (
294
+ 'Run Now'
295
+ )}
296
+ </button>
297
+ <button
298
+ type="button"
299
+ className="p-2 hover:bg-muted rounded-lg transition-all text-muted-foreground"
300
+ >
301
+ <AlertCircle size={18} />
302
+ </button>
303
+ <button
304
+ type="button"
305
+ onClick={() => setConfirmDeleteId(schedule.id)}
306
+ className="p-2 hover:bg-red-500/10 hover:text-red-500 rounded-lg transition-all text-muted-foreground"
307
+ >
308
+ <Trash2 size={18} />
309
+ </button>
310
+ </div>
311
+ </div>
312
+
313
+ <div className="mt-6 grid grid-cols-2 gap-4 pt-6 border-t border-border/30">
314
+ <div className="flex items-center gap-3">
315
+ <div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground/60">
316
+ <Calendar size={14} />
317
+ </div>
318
+ <div>
319
+ <p className="text-[10px] font-black text-muted-foreground/40 uppercase tracking-widest leading-none mb-1">
320
+ Last Run
321
+ </p>
322
+ <p className="text-xs font-bold">
323
+ {schedule.lastRun
324
+ ? format(new Date(schedule.lastRun), 'HH:mm:ss MMM dd')
325
+ : 'Never'}
326
+ </p>
327
+ </div>
328
+ </div>
329
+ <div className="flex items-center gap-3">
330
+ <div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
331
+ <Play size={14} />
332
+ </div>
333
+ <div>
334
+ <p className="text-[10px] font-black text-muted-foreground/40 uppercase tracking-widest leading-none mb-1">
335
+ Next Run
336
+ </p>
337
+ <p className="text-xs font-bold">
338
+ {schedule.nextRun
339
+ ? format(new Date(schedule.nextRun), 'HH:mm:ss MMM dd')
340
+ : 'Scheduled'}
341
+ </p>
342
+ </div>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ ))}
347
+ </div>
348
+
349
+ {/* New Schedule Modal */}
350
+ <AnimatePresence>
351
+ {isModalOpen && (
352
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
353
+ <motion.div
354
+ initial={{ opacity: 0 }}
355
+ animate={{ opacity: 1 }}
356
+ exit={{ opacity: 0 }}
357
+ onClick={() => setIsModalOpen(false)}
358
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm"
359
+ />
360
+ <motion.div
361
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
362
+ animate={{ opacity: 1, scale: 1, y: 0 }}
363
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
364
+ className="relative w-full max-w-lg bg-card border border-border/50 rounded-3xl shadow-2xl overflow-hidden"
365
+ >
366
+ <div className="p-6 border-b flex items-center justify-between bg-muted/5">
367
+ <div className="flex items-center gap-3">
368
+ <div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
369
+ <Plus size={20} />
370
+ </div>
371
+ <div>
372
+ <h2 className="text-lg font-bold leading-none">Register Schedule</h2>
373
+ <p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest mt-1">
374
+ Add new recurring job
375
+ </p>
376
+ </div>
377
+ </div>
378
+ <button
379
+ type="button"
380
+ onClick={() => setIsModalOpen(false)}
381
+ className="p-2 hover:bg-muted rounded-full transition-all"
382
+ >
383
+ <X size={20} />
384
+ </button>
385
+ </div>
386
+
387
+ <form onSubmit={handleSubmit} className="p-6 space-y-5">
388
+ <div className="space-y-2">
389
+ <label
390
+ htmlFor="schedule-id"
391
+ className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1"
392
+ >
393
+ Schedule Identifier (ID)
394
+ </label>
395
+ <input
396
+ id="schedule-id"
397
+ required
398
+ type="text"
399
+ placeholder="daily-cleanup-job"
400
+ className="w-full bg-muted/40 border-border/50 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-primary/30 outline-none transition-all"
401
+ value={formData.id}
402
+ onChange={(e) => setFormData({ ...formData, id: e.target.value })}
403
+ />
404
+ </div>
405
+
406
+ <div className="grid grid-cols-2 gap-4">
407
+ <div className="space-y-2">
408
+ <label
409
+ htmlFor="cron-expression"
410
+ className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1"
411
+ >
412
+ Cron Expression
413
+ </label>
414
+ <input
415
+ id="cron-expression"
416
+ required
417
+ type="text"
418
+ placeholder="* * * * *"
419
+ className="w-full bg-muted/40 border-border/50 rounded-xl px-4 py-3 text-sm font-mono focus:ring-1 focus:ring-primary/30 outline-none transition-all"
420
+ value={formData.cron}
421
+ onChange={(e) => setFormData({ ...formData, cron: e.target.value })}
422
+ />
423
+ </div>
424
+ <div className="space-y-2">
425
+ <label
426
+ htmlFor="target-queue"
427
+ className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1"
428
+ >
429
+ Target Queue
430
+ </label>
431
+ <div className="relative">
432
+ <select
433
+ id="target-queue"
434
+ className="w-full bg-muted/40 border-border/50 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-primary/30 outline-none transition-all appearance-none cursor-pointer"
435
+ value={formData.queue}
436
+ onChange={(e) => setFormData({ ...formData, queue: e.target.value })}
437
+ >
438
+ {queues.map((q: any) => (
439
+ <option key={q.name} value={q.name}>
440
+ {q.name}
441
+ </option>
442
+ ))}
443
+ </select>
444
+ <ChevronDown
445
+ className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
446
+ size={16}
447
+ />
448
+ </div>
449
+ </div>
450
+ </div>
451
+
452
+ <div className="space-y-2">
453
+ <label
454
+ htmlFor="job-class-name"
455
+ className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1"
456
+ >
457
+ Job Class Name
458
+ </label>
459
+ <input
460
+ id="job-class-name"
461
+ required
462
+ type="text"
463
+ placeholder="CleanupJob"
464
+ className="w-full bg-muted/40 border-border/50 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-primary/30 outline-none transition-all font-mono"
465
+ value={formData.className}
466
+ onChange={(e) => setFormData({ ...formData, className: e.target.value })}
467
+ />
468
+ </div>
469
+
470
+ <div className="space-y-2">
471
+ <label
472
+ htmlFor="job-payload"
473
+ className="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1"
474
+ >
475
+ Job Payload (JSON)
476
+ </label>
477
+ <textarea
478
+ id="job-payload"
479
+ rows={3}
480
+ className="w-full bg-muted/40 border-border/50 rounded-xl px-4 py-3 text-sm font-mono focus:ring-1 focus:ring-primary/30 outline-none transition-all resize-none"
481
+ value={formData.payload}
482
+ onChange={(e) => setFormData({ ...formData, payload: e.target.value })}
483
+ />
484
+ </div>
485
+
486
+ <div className="pt-4 flex gap-3">
487
+ <button
488
+ type="button"
489
+ onClick={() => setIsModalOpen(false)}
490
+ className="flex-1 py-3 border border-border/50 rounded-xl text-xs font-black uppercase tracking-widest hover:bg-muted transition-all"
491
+ >
492
+ Cancel
493
+ </button>
494
+ <button
495
+ type="submit"
496
+ disabled={registerMutation.isPending}
497
+ className="flex-1 py-3 bg-primary text-primary-foreground rounded-xl text-xs font-black uppercase tracking-widest shadow-lg shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all disabled:opacity-50"
498
+ >
499
+ {registerMutation.isPending ? 'Registering...' : 'Register Schedule'}
500
+ </button>
501
+ </div>
502
+ </form>
503
+ </motion.div>
504
+ </div>
505
+ )}
506
+ </AnimatePresence>
507
+
508
+ <ConfirmDialog
509
+ open={!!confirmRunId}
510
+ title="Manual trigger confirmation"
511
+ message={`Are you sure you want to run "${confirmRunId}" immediately?\nThis will bypass the next scheduled time and push a job to the "${schedules.find((s) => s.id === confirmRunId)?.queue}" queue.`}
512
+ confirmText="Run Now"
513
+ variant="info"
514
+ isProcessing={runMutation.isPending}
515
+ onConfirm={() => confirmRunId && runMutation.mutate(confirmRunId)}
516
+ onCancel={() => setConfirmRunId(null)}
517
+ />
518
+
519
+ <ConfirmDialog
520
+ open={!!confirmDeleteId}
521
+ title="Delete Schedule"
522
+ message={`Are you sure you want to delete "${confirmDeleteId}"?\nThis action cannot be undone and recurring jobs for this schedule will stop.`}
523
+ confirmText="Delete"
524
+ variant="danger"
525
+ isProcessing={deleteMutation.isPending}
526
+ onConfirm={() => confirmDeleteId && deleteMutation.mutate(confirmDeleteId)}
527
+ onCancel={() => setConfirmDeleteId(null)}
528
+ />
529
+ </div>
530
+ )
531
+ }