mongo-scheduler-ui 1.0.0
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.
- package/.env.example +1 -0
- package/README.md +122 -0
- package/eslint.config.js +23 -0
- package/index.html +13 -0
- package/package.json +44 -0
- package/postcss.config.js +6 -0
- package/public/dashboard-preview.png +0 -0
- package/public/favicon.svg +12 -0
- package/src/App.css +42 -0
- package/src/App.tsx +233 -0
- package/src/components/JobDetails.tsx +124 -0
- package/src/components/JobFilters.tsx +59 -0
- package/src/components/JobFormModal.tsx +372 -0
- package/src/components/JobList.tsx +171 -0
- package/src/components/Pagination.tsx +107 -0
- package/src/components/StatsCards.tsx +52 -0
- package/src/index.css +29 -0
- package/src/main.tsx +10 -0
- package/src/services/api.ts +58 -0
- package/src/types/job.ts +43 -0
- package/src/utils/helpers.ts +31 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +7 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
X,
|
|
4
|
+
Save,
|
|
5
|
+
Clock,
|
|
6
|
+
Repeat,
|
|
7
|
+
AlertCircle,
|
|
8
|
+
Database,
|
|
9
|
+
Globe,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import type { Job } from "../types/job";
|
|
12
|
+
|
|
13
|
+
interface JobFormModalProps {
|
|
14
|
+
job?: Job | null;
|
|
15
|
+
isOpen: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
onSave: (jobData: Partial<Job>) => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function JobFormModal({
|
|
21
|
+
job,
|
|
22
|
+
isOpen,
|
|
23
|
+
onClose,
|
|
24
|
+
onSave,
|
|
25
|
+
}: JobFormModalProps) {
|
|
26
|
+
const [loading, setLoading] = useState(false);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
// Form State
|
|
30
|
+
const [name, setName] = useState("");
|
|
31
|
+
const [runAt, setRunAt] = useState("");
|
|
32
|
+
const [dataJson, setDataJson] = useState("{}");
|
|
33
|
+
const [repeatType, setRepeatType] = useState<"none" | "cron" | "every">(
|
|
34
|
+
"none"
|
|
35
|
+
);
|
|
36
|
+
const [cronExpression, setCronExpression] = useState("");
|
|
37
|
+
const [everyInterval, setEveryInterval] = useState("");
|
|
38
|
+
const [timezone, setTimezone] = useState("");
|
|
39
|
+
const [maxRetries, setMaxRetries] = useState("3");
|
|
40
|
+
const [retryDelay, setRetryDelay] = useState("1000");
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (isOpen) {
|
|
44
|
+
if (job) {
|
|
45
|
+
// Edit Mode
|
|
46
|
+
setName(job.name);
|
|
47
|
+
setRunAt(
|
|
48
|
+
job.nextRunAt
|
|
49
|
+
? new Date(job.nextRunAt).toISOString().slice(0, 16)
|
|
50
|
+
: ""
|
|
51
|
+
);
|
|
52
|
+
setDataJson(JSON.stringify(job.data || {}, null, 2));
|
|
53
|
+
|
|
54
|
+
if (job.repeat?.cron) {
|
|
55
|
+
setRepeatType("cron");
|
|
56
|
+
setCronExpression(job.repeat.cron);
|
|
57
|
+
setTimezone(job.repeat.timezone || "");
|
|
58
|
+
} else if (job.repeat?.every) {
|
|
59
|
+
setRepeatType("every");
|
|
60
|
+
setEveryInterval(job.repeat.every.toString());
|
|
61
|
+
} else {
|
|
62
|
+
setRepeatType("none");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (job.retry) {
|
|
66
|
+
setMaxRetries(job.retry.maxAttempts?.toString() || "3");
|
|
67
|
+
const delay =
|
|
68
|
+
typeof job.retry.delay === "number" ? job.retry.delay : 1000;
|
|
69
|
+
setRetryDelay(delay.toString());
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
// Create Mode - Reset
|
|
73
|
+
setName("");
|
|
74
|
+
setRunAt("");
|
|
75
|
+
setDataJson("{}");
|
|
76
|
+
setRepeatType("none");
|
|
77
|
+
setCronExpression("");
|
|
78
|
+
setTimezone("");
|
|
79
|
+
setEveryInterval("");
|
|
80
|
+
setMaxRetries("3");
|
|
81
|
+
setRetryDelay("1000");
|
|
82
|
+
}
|
|
83
|
+
setError(null);
|
|
84
|
+
}
|
|
85
|
+
}, [isOpen, job]);
|
|
86
|
+
|
|
87
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setLoading(true);
|
|
90
|
+
setError(null);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
let parsedData = {};
|
|
94
|
+
try {
|
|
95
|
+
parsedData = JSON.parse(dataJson);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new Error("Invalid JSON in Data field");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const payload: any = {
|
|
101
|
+
name,
|
|
102
|
+
data: parsedData,
|
|
103
|
+
runAt: runAt ? new Date(runAt).toISOString() : undefined,
|
|
104
|
+
retry: {
|
|
105
|
+
maxAttempts: parseInt(maxRetries),
|
|
106
|
+
delay: parseInt(retryDelay),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (repeatType === "cron" && cronExpression) {
|
|
111
|
+
payload.repeat = {
|
|
112
|
+
cron: cronExpression,
|
|
113
|
+
...(timezone && { timezone }),
|
|
114
|
+
};
|
|
115
|
+
} else if (repeatType === "every" && everyInterval) {
|
|
116
|
+
payload.repeat = { every: parseInt(everyInterval) };
|
|
117
|
+
} else {
|
|
118
|
+
payload.repeat = undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await onSave(payload);
|
|
122
|
+
onClose();
|
|
123
|
+
} catch (err: any) {
|
|
124
|
+
setError(err.message || "Failed to save job");
|
|
125
|
+
} finally {
|
|
126
|
+
setLoading(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (!isOpen) return null;
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 overflow-y-auto">
|
|
134
|
+
<div className="bg-gray-800 border border-gray-700 rounded-xl w-full max-w-2xl shadow-xl flex flex-col max-h-[90vh]">
|
|
135
|
+
{/* Header */}
|
|
136
|
+
<div className="flex items-center justify-between p-6 border-b border-gray-700 bg-gray-800/50 rounded-t-xl sticky top-0 z-10">
|
|
137
|
+
<div className="flex items-center gap-3">
|
|
138
|
+
<div className="p-2 bg-blue-500/10 rounded-lg">
|
|
139
|
+
{job ? (
|
|
140
|
+
<Database className="w-5 h-5 text-blue-400" />
|
|
141
|
+
) : (
|
|
142
|
+
<Save className="w-5 h-5 text-green-400" />
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
<h2 className="text-xl font-semibold text-white">
|
|
146
|
+
{job ? "Edit Job" : "Create New Job"}
|
|
147
|
+
</h2>
|
|
148
|
+
</div>
|
|
149
|
+
<button
|
|
150
|
+
onClick={onClose}
|
|
151
|
+
className="text-gray-400 hover:text-white transition-colors"
|
|
152
|
+
>
|
|
153
|
+
<X className="w-6 h-6" />
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Body */}
|
|
158
|
+
<form
|
|
159
|
+
onSubmit={handleSubmit}
|
|
160
|
+
className="p-6 space-y-6 overflow-y-auto flex-1"
|
|
161
|
+
>
|
|
162
|
+
{error && (
|
|
163
|
+
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 flex items-center gap-3 text-red-400">
|
|
164
|
+
<AlertCircle className="w-5 h-5" />
|
|
165
|
+
<p>{error}</p>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
<div className="space-y-4">
|
|
170
|
+
{/* Name */}
|
|
171
|
+
<div>
|
|
172
|
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
|
173
|
+
Job Name
|
|
174
|
+
</label>
|
|
175
|
+
<input
|
|
176
|
+
type="text"
|
|
177
|
+
required
|
|
178
|
+
value={name}
|
|
179
|
+
onChange={(e) => setName(e.target.value)}
|
|
180
|
+
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all"
|
|
181
|
+
placeholder="e.g. send-email-campaign"
|
|
182
|
+
disabled={!!job} // Usually name is immutable or hard to change logic for, but API lets us. Let's keep editable if API allows, but typical patterns lock it. User specifically asked for Create UI for API, API code uses name. I will leave it enabled. Actually, re-reading API code, update doesn't take name. So disable if job exists.
|
|
183
|
+
/>
|
|
184
|
+
{job && (
|
|
185
|
+
<p className="text-xs text-gray-500 mt-1">
|
|
186
|
+
Job name cannot be changed once created.
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* Run At */}
|
|
192
|
+
<div>
|
|
193
|
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
|
194
|
+
Run At (Optional)
|
|
195
|
+
</label>
|
|
196
|
+
<div className="relative">
|
|
197
|
+
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
|
198
|
+
<input
|
|
199
|
+
type="datetime-local"
|
|
200
|
+
value={runAt}
|
|
201
|
+
onChange={(e) => setRunAt(e.target.value)}
|
|
202
|
+
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all [&::-webkit-calendar-picker-indicator]:invert"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
<p className="text-xs text-gray-500 mt-1">
|
|
206
|
+
Leave empty to run immediately (or based on repeat schedule)
|
|
207
|
+
</p>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* Repeat Configuration */}
|
|
211
|
+
<div>
|
|
212
|
+
<label className="block text-sm font-medium text-gray-300 mb-1 flex items-center gap-2">
|
|
213
|
+
<Repeat className="w-4 h-4 text-gray-400" />
|
|
214
|
+
Repeat Schedule
|
|
215
|
+
</label>
|
|
216
|
+
<div className="grid grid-cols-3 gap-3 mb-3">
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={() => setRepeatType("none")}
|
|
220
|
+
className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${
|
|
221
|
+
repeatType === "none"
|
|
222
|
+
? "bg-blue-500/10 border-blue-500 text-blue-400"
|
|
223
|
+
: "bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600"
|
|
224
|
+
}`}
|
|
225
|
+
>
|
|
226
|
+
None
|
|
227
|
+
</button>
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
onClick={() => setRepeatType("cron")}
|
|
231
|
+
className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${
|
|
232
|
+
repeatType === "cron"
|
|
233
|
+
? "bg-blue-500/10 border-blue-500 text-blue-400"
|
|
234
|
+
: "bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600"
|
|
235
|
+
}`}
|
|
236
|
+
>
|
|
237
|
+
Cron
|
|
238
|
+
</button>
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={() => setRepeatType("every")}
|
|
242
|
+
className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${
|
|
243
|
+
repeatType === "every"
|
|
244
|
+
? "bg-blue-500/10 border-blue-500 text-blue-400"
|
|
245
|
+
: "bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600"
|
|
246
|
+
}`}
|
|
247
|
+
>
|
|
248
|
+
Interval
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{repeatType === "cron" && (
|
|
253
|
+
<div className="space-y-3">
|
|
254
|
+
<div>
|
|
255
|
+
<input
|
|
256
|
+
type="text"
|
|
257
|
+
value={cronExpression}
|
|
258
|
+
onChange={(e) => setCronExpression(e.target.value)}
|
|
259
|
+
placeholder="Cron Expression (* * * * *)"
|
|
260
|
+
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
<div className="relative">
|
|
264
|
+
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
|
265
|
+
<input
|
|
266
|
+
type="text"
|
|
267
|
+
list="timezones"
|
|
268
|
+
value={timezone}
|
|
269
|
+
onChange={(e) => setTimezone(e.target.value)}
|
|
270
|
+
placeholder="Timezone (e.g. UTC, America/New_York)"
|
|
271
|
+
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
|
|
272
|
+
/>
|
|
273
|
+
<datalist id="timezones">
|
|
274
|
+
<option value="UTC" />
|
|
275
|
+
<option value="America/New_York" />
|
|
276
|
+
<option value="America/Los_Angeles" />
|
|
277
|
+
<option value="Europe/London" />
|
|
278
|
+
<option value="Europe/Paris" />
|
|
279
|
+
<option value="Asia/Tokyo" />
|
|
280
|
+
<option value="Asia/Kolkata" />
|
|
281
|
+
<option value="Australia/Sydney" />
|
|
282
|
+
</datalist>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
{repeatType === "every" && (
|
|
287
|
+
<div className="flex items-center gap-2">
|
|
288
|
+
<input
|
|
289
|
+
type="number"
|
|
290
|
+
value={everyInterval}
|
|
291
|
+
onChange={(e) => setEveryInterval(e.target.value)}
|
|
292
|
+
placeholder="Time in ms"
|
|
293
|
+
className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
|
|
294
|
+
/>
|
|
295
|
+
<span className="text-gray-400 text-sm">ms</span>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Retry Configuration */}
|
|
301
|
+
<div className="grid grid-cols-2 gap-4">
|
|
302
|
+
<div>
|
|
303
|
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
|
304
|
+
Max Retries
|
|
305
|
+
</label>
|
|
306
|
+
<input
|
|
307
|
+
type="number"
|
|
308
|
+
min="0"
|
|
309
|
+
value={maxRetries}
|
|
310
|
+
onChange={(e) => setMaxRetries(e.target.value)}
|
|
311
|
+
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
<div>
|
|
315
|
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
|
316
|
+
Retry Delay (ms)
|
|
317
|
+
</label>
|
|
318
|
+
<input
|
|
319
|
+
type="number"
|
|
320
|
+
min="0"
|
|
321
|
+
value={retryDelay}
|
|
322
|
+
onChange={(e) => setRetryDelay(e.target.value)}
|
|
323
|
+
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{/* Data JSON */}
|
|
329
|
+
<div>
|
|
330
|
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
|
331
|
+
Data Payload (JSON)
|
|
332
|
+
</label>
|
|
333
|
+
<textarea
|
|
334
|
+
value={dataJson}
|
|
335
|
+
onChange={(e) => setDataJson(e.target.value)}
|
|
336
|
+
rows={5}
|
|
337
|
+
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2.5 text-white font-mono text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all resize-none"
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
</form>
|
|
342
|
+
|
|
343
|
+
{/* Footer */}
|
|
344
|
+
<div className="p-6 border-t border-gray-700 bg-gray-800/50 rounded-b-xl flex justify-end gap-3 sticky bottom-0 z-10">
|
|
345
|
+
<button
|
|
346
|
+
onClick={onClose}
|
|
347
|
+
className="px-4 py-2 text-gray-300 hover:text-white transition-colors"
|
|
348
|
+
>
|
|
349
|
+
Cancel
|
|
350
|
+
</button>
|
|
351
|
+
<button
|
|
352
|
+
onClick={handleSubmit}
|
|
353
|
+
disabled={loading}
|
|
354
|
+
className="px-6 py-2 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-medium rounded-lg hover:from-blue-600 hover:to-purple-700 transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
355
|
+
>
|
|
356
|
+
{loading ? (
|
|
357
|
+
<>
|
|
358
|
+
<span className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
|
359
|
+
Saving...
|
|
360
|
+
</>
|
|
361
|
+
) : (
|
|
362
|
+
<>
|
|
363
|
+
<Save className="w-4 h-4" />
|
|
364
|
+
{job ? "Update Job" : "Create Job"}
|
|
365
|
+
</>
|
|
366
|
+
)}
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { RefreshCw, Trash2, RotateCcw, X, Edit } from 'lucide-react';
|
|
2
|
+
import type { Job } from '../types/job';
|
|
3
|
+
import { formatDate, formatRelativeTime, getStatusColor } from '../utils/helpers';
|
|
4
|
+
import Pagination from './Pagination';
|
|
5
|
+
|
|
6
|
+
interface JobListProps {
|
|
7
|
+
jobs: Job[];
|
|
8
|
+
loading: boolean;
|
|
9
|
+
onJobClick: (job: Job) => void;
|
|
10
|
+
onEdit: (job: Job) => void;
|
|
11
|
+
onRefresh: () => void;
|
|
12
|
+
onDelete: (id: string) => void;
|
|
13
|
+
onRetry: (id: string) => void;
|
|
14
|
+
onCancel: (id: string) => void;
|
|
15
|
+
// Pagination Props
|
|
16
|
+
currentPage: number;
|
|
17
|
+
totalPages: number;
|
|
18
|
+
onPageChange: (page: number) => void;
|
|
19
|
+
itemsPerPage: number;
|
|
20
|
+
onItemsPerPageChange: (items: number) => void;
|
|
21
|
+
totalItems: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function JobList({
|
|
25
|
+
jobs,
|
|
26
|
+
loading,
|
|
27
|
+
onJobClick,
|
|
28
|
+
onEdit,
|
|
29
|
+
onRefresh,
|
|
30
|
+
onDelete,
|
|
31
|
+
onRetry,
|
|
32
|
+
onCancel,
|
|
33
|
+
currentPage,
|
|
34
|
+
totalPages,
|
|
35
|
+
onPageChange,
|
|
36
|
+
itemsPerPage,
|
|
37
|
+
onItemsPerPageChange,
|
|
38
|
+
totalItems
|
|
39
|
+
}: JobListProps) {
|
|
40
|
+
if (loading) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="space-y-3">
|
|
43
|
+
{[...Array(5)].map((_, i) => (
|
|
44
|
+
<div key={i} className="bg-gray-800/50 rounded-lg p-4 animate-pulse">
|
|
45
|
+
<div className="h-4 bg-gray-700 rounded w-3/4 mb-2"></div>
|
|
46
|
+
<div className="h-3 bg-gray-700 rounded w-1/2"></div>
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (jobs.length === 0 && totalItems === 0) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="text-center py-16 text-gray-400">
|
|
56
|
+
<p className="text-lg">No jobs found</p>
|
|
57
|
+
<p className="text-sm mt-2">Try adjusting your filters</p>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="space-y-3">
|
|
64
|
+
<div className="flex flex-col xl:flex-row justify-between items-start xl:items-center mb-4 gap-4">
|
|
65
|
+
<h3 className="text-lg font-semibold text-white">Jobs</h3>
|
|
66
|
+
|
|
67
|
+
<div className="flex items-center gap-2 self-end xl:self-auto">
|
|
68
|
+
<Pagination
|
|
69
|
+
currentPage={currentPage}
|
|
70
|
+
totalPages={totalPages}
|
|
71
|
+
onPageChange={onPageChange}
|
|
72
|
+
itemsPerPage={itemsPerPage}
|
|
73
|
+
onItemsPerPageChange={onItemsPerPageChange}
|
|
74
|
+
totalItems={totalItems}
|
|
75
|
+
className="!py-0"
|
|
76
|
+
/>
|
|
77
|
+
<div className="h-8 w-px bg-gray-700 mx-2 hidden sm:block"></div>
|
|
78
|
+
<button
|
|
79
|
+
onClick={onRefresh}
|
|
80
|
+
className="p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors border border-gray-700/50"
|
|
81
|
+
title="Refresh"
|
|
82
|
+
>
|
|
83
|
+
<RefreshCw className="w-5 h-5 text-gray-400" />
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{jobs.map((job) => (
|
|
89
|
+
<div
|
|
90
|
+
key={job._id}
|
|
91
|
+
onClick={() => onJobClick(job)}
|
|
92
|
+
className="bg-gradient-to-br from-gray-800/80 to-gray-900/80 backdrop-blur-sm rounded-lg p-4 border border-gray-700/50 hover:border-gray-600/50 cursor-pointer transition-all duration-200 group"
|
|
93
|
+
>
|
|
94
|
+
<div className="flex items-start justify-between mb-2">
|
|
95
|
+
<div className="flex-1">
|
|
96
|
+
<h4 className="text-white font-medium mb-1">{job.name}</h4>
|
|
97
|
+
<div className="flex items-center gap-2 text-sm text-gray-400">
|
|
98
|
+
<span className={`px-2 py-1 rounded text-xs border ${getStatusColor(job.status)}`}>
|
|
99
|
+
{job.status}
|
|
100
|
+
</span>
|
|
101
|
+
<span>•</span>
|
|
102
|
+
<span title={formatDate(job.createdAt)}>Created {formatRelativeTime(job.createdAt)}</span>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
107
|
+
<button
|
|
108
|
+
onClick={(e) => { e.stopPropagation(); onEdit(job); }}
|
|
109
|
+
className="p-2 rounded bg-green-500/20 hover:bg-green-500/30 text-green-400"
|
|
110
|
+
title="Edit"
|
|
111
|
+
>
|
|
112
|
+
<Edit className="w-4 h-4" />
|
|
113
|
+
</button>
|
|
114
|
+
{job.status === 'failed' && (
|
|
115
|
+
<button
|
|
116
|
+
onClick={(e) => { e.stopPropagation(); onRetry(job._id); }}
|
|
117
|
+
className="p-2 rounded bg-blue-500/20 hover:bg-blue-500/30 text-blue-400"
|
|
118
|
+
title="Retry"
|
|
119
|
+
>
|
|
120
|
+
<RotateCcw className="w-4 h-4" />
|
|
121
|
+
</button>
|
|
122
|
+
)}
|
|
123
|
+
{(job.status === 'pending' || job.status === 'running') && (
|
|
124
|
+
<button
|
|
125
|
+
onClick={(e) => { e.stopPropagation(); onCancel(job._id); }}
|
|
126
|
+
className="p-2 rounded bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400"
|
|
127
|
+
title="Cancel"
|
|
128
|
+
>
|
|
129
|
+
<X className="w-4 h-4" />
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
<button
|
|
133
|
+
onClick={(e) => { e.stopPropagation(); onDelete(job._id); }}
|
|
134
|
+
className="p-2 rounded bg-red-500/20 hover:bg-red-500/30 text-red-400"
|
|
135
|
+
title="Delete"
|
|
136
|
+
>
|
|
137
|
+
<Trash2 className="w-4 h-4" />
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{job.lastError && (
|
|
143
|
+
<div className="mt-2 text-xs text-red-400 bg-red-500/10 rounded px-2 py-1">
|
|
144
|
+
Error: {job.lastError.substring(0, 100)}
|
|
145
|
+
{job.lastError.length > 100 && '...'}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
<div className="mt-2 flex gap-4 text-xs text-gray-500">
|
|
150
|
+
{job.nextRunAt && (
|
|
151
|
+
<span className="font-medium text-blue-300">
|
|
152
|
+
Run {formatRelativeTime(job.nextRunAt)}
|
|
153
|
+
</span>
|
|
154
|
+
)}
|
|
155
|
+
{job.nextRunAt && (
|
|
156
|
+
<span className="text-gray-600">
|
|
157
|
+
({formatDate(job.nextRunAt)})
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
{job.attempts > 0 && (
|
|
161
|
+
<span>Attempts: {job.attempts}</span>
|
|
162
|
+
)}
|
|
163
|
+
{job.repeat && (
|
|
164
|
+
<span className="text-purple-400">🔁 Repeating</span>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
interface PaginationProps {
|
|
4
|
+
currentPage: number;
|
|
5
|
+
totalPages: number;
|
|
6
|
+
onPageChange: (page: number) => void;
|
|
7
|
+
itemsPerPage: number;
|
|
8
|
+
onItemsPerPageChange: (items: number) => void;
|
|
9
|
+
totalItems: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function Pagination({
|
|
14
|
+
currentPage,
|
|
15
|
+
totalPages,
|
|
16
|
+
onPageChange,
|
|
17
|
+
itemsPerPage,
|
|
18
|
+
onItemsPerPageChange,
|
|
19
|
+
totalItems,
|
|
20
|
+
className = "",
|
|
21
|
+
}: PaginationProps) {
|
|
22
|
+
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
|
23
|
+
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
|
24
|
+
|
|
25
|
+
if (totalItems === 0) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className={`flex flex-col sm:flex-row items-center justify-between gap-4 ${className}`}>
|
|
29
|
+
{/* Stats */}
|
|
30
|
+
<div className="text-sm text-gray-400">
|
|
31
|
+
Showing <span className="font-medium text-white">{startItem}</span> to{' '}
|
|
32
|
+
<span className="font-medium text-white">{endItem}</span> of{' '}
|
|
33
|
+
<span className="font-medium text-white">{totalItems}</span> results
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div className="flex items-center gap-4">
|
|
37
|
+
{/* Rows per page */}
|
|
38
|
+
<div className="flex items-center gap-2">
|
|
39
|
+
<span className="text-sm text-gray-400">Rows per page:</span>
|
|
40
|
+
<select
|
|
41
|
+
value={itemsPerPage}
|
|
42
|
+
onChange={(e) => {
|
|
43
|
+
onItemsPerPageChange(Number(e.target.value));
|
|
44
|
+
onPageChange(1); // Reset to page 1 when changing items per page
|
|
45
|
+
}}
|
|
46
|
+
className="bg-gray-800 border border-gray-700 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-1.5 outline-none"
|
|
47
|
+
>
|
|
48
|
+
<option value={5}>5</option>
|
|
49
|
+
<option value={10}>10</option>
|
|
50
|
+
<option value={20}>20</option>
|
|
51
|
+
<option value={50}>50</option>
|
|
52
|
+
</select>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* Navigation */}
|
|
56
|
+
<div className="flex items-center gap-1">
|
|
57
|
+
<button
|
|
58
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
59
|
+
disabled={currentPage === 1}
|
|
60
|
+
className="p-2 rounded-lg bg-gray-800/50 border border-gray-700/50 text-gray-400 hover:text-white hover:bg-gray-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
61
|
+
aria-label="Previous page"
|
|
62
|
+
>
|
|
63
|
+
<ChevronLeft className="w-4 h-4" />
|
|
64
|
+
</button>
|
|
65
|
+
|
|
66
|
+
<div className="flex items-center gap-1 px-2">
|
|
67
|
+
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
68
|
+
// Logic to show a window of pages around current page
|
|
69
|
+
let pageNum = i + 1;
|
|
70
|
+
if (totalPages > 5) {
|
|
71
|
+
if (currentPage > 3) {
|
|
72
|
+
pageNum = currentPage - 3 + i + 1;
|
|
73
|
+
}
|
|
74
|
+
if (pageNum > totalPages) {
|
|
75
|
+
pageNum = totalPages - (4 - i);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<button
|
|
81
|
+
key={pageNum}
|
|
82
|
+
onClick={() => onPageChange(pageNum)}
|
|
83
|
+
className={`w-8 h-8 rounded-lg text-sm font-medium transition-all ${
|
|
84
|
+
currentPage === pageNum
|
|
85
|
+
? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20'
|
|
86
|
+
: 'bg-gray-800/50 border border-gray-700/50 text-gray-400 hover:text-white hover:bg-gray-700/50'
|
|
87
|
+
}`}
|
|
88
|
+
>
|
|
89
|
+
{pageNum}
|
|
90
|
+
</button>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<button
|
|
96
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
97
|
+
disabled={currentPage === totalPages}
|
|
98
|
+
className="p-2 rounded-lg bg-gray-800/50 border border-gray-700/50 text-gray-400 hover:text-white hover:bg-gray-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
99
|
+
aria-label="Next page"
|
|
100
|
+
>
|
|
101
|
+
<ChevronRight className="w-4 h-4" />
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|