@ventually/ui 0.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/README.md +51 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +163 -0
- package/dist/context.d.ts +20 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +76 -0
- package/dist/context.test.d.ts +2 -0
- package/dist/context.test.d.ts.map +1 -0
- package/dist/context.test.js +26 -0
- package/dist/dashboard.d.ts +3 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +273 -0
- package/dist/index.cjs +15 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/runtime.d.ts +47 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +303 -0
- package/dist/shared-types.d.ts +71 -0
- package/dist/shared-types.d.ts.map +1 -0
- package/dist/shared-types.js +1 -0
- package/dist/standalone/assets/index-BM0MHT5H.js +18 -0
- package/dist/standalone/assets/index-CvxuexK-.css +1 -0
- package/dist/standalone/index.html +13 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +59 -0
- package/src/cli.ts +215 -0
- package/src/context.test.tsx +47 -0
- package/src/context.tsx +130 -0
- package/src/dashboard.tsx +835 -0
- package/src/index.css +1 -0
- package/src/index.ts +11 -0
- package/src/runtime.ts +414 -0
- package/src/shared-types.ts +67 -0
- package/src/standalone/App.tsx +31 -0
- package/src/standalone/index.html +12 -0
- package/src/standalone/main.tsx +10 -0
- package/src/types.ts +9 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
import "./index.css";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { useEventuallyUI } from "./context.js";
|
|
10
|
+
import type { EventuallyUIJob, EventuallyUIJobState } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const LIVE_EVENTS_MIN_HEIGHT = 144;
|
|
13
|
+
const LIVE_EVENTS_MAX_HEIGHT = 480;
|
|
14
|
+
|
|
15
|
+
function formatRelative(value: number) {
|
|
16
|
+
const delta = Math.max(0, Date.now() - value);
|
|
17
|
+
if (delta < 1_000) return `${delta}ms`;
|
|
18
|
+
if (delta < 60_000) return `${Math.floor(delta / 1_000)}s ago`;
|
|
19
|
+
return `${Math.floor(delta / 60_000)}m ago`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatDuration(job: EventuallyUIJob) {
|
|
23
|
+
if (job.state === "delayed") {
|
|
24
|
+
const remaining = Math.max(0, job.availableAt - Date.now());
|
|
25
|
+
if (remaining < 60_000) return `in ${Math.ceil(remaining / 1_000)}s`;
|
|
26
|
+
const m = Math.floor(remaining / 60_000);
|
|
27
|
+
const s = Math.floor((remaining % 60_000) / 1_000);
|
|
28
|
+
return `in ${m}m ${s}s`;
|
|
29
|
+
}
|
|
30
|
+
if (job.finishedAt && job.processedAt)
|
|
31
|
+
return `${job.finishedAt - job.processedAt}ms`;
|
|
32
|
+
if (job.processedAt) return `${Math.max(0, Date.now() - job.processedAt)}ms`;
|
|
33
|
+
return "—";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const STATE_STYLES: Record<
|
|
37
|
+
EventuallyUIJobState,
|
|
38
|
+
{ dot: string; badge: string; label: string }
|
|
39
|
+
> = {
|
|
40
|
+
active: {
|
|
41
|
+
dot: "bg-amber-400",
|
|
42
|
+
badge: "bg-amber-400/10 text-amber-400 ring-1 ring-amber-400/25",
|
|
43
|
+
label: "active",
|
|
44
|
+
},
|
|
45
|
+
waiting: {
|
|
46
|
+
dot: "bg-blue-400",
|
|
47
|
+
badge: "bg-blue-400/10 text-blue-400 ring-1 ring-blue-400/25",
|
|
48
|
+
label: "waiting",
|
|
49
|
+
},
|
|
50
|
+
completed: {
|
|
51
|
+
dot: "bg-emerald-400",
|
|
52
|
+
badge: "bg-emerald-400/10 text-emerald-400 ring-1 ring-emerald-400/25",
|
|
53
|
+
label: "completed",
|
|
54
|
+
},
|
|
55
|
+
failed: {
|
|
56
|
+
dot: "bg-red-400",
|
|
57
|
+
badge: "bg-red-400/10 text-red-400 ring-1 ring-red-400/25",
|
|
58
|
+
label: "failed",
|
|
59
|
+
},
|
|
60
|
+
delayed: {
|
|
61
|
+
dot: "bg-violet-400",
|
|
62
|
+
badge: "bg-violet-400/10 text-violet-400 ring-1 ring-violet-400/25",
|
|
63
|
+
label: "delayed",
|
|
64
|
+
},
|
|
65
|
+
blocked: {
|
|
66
|
+
dot: "bg-violet-400",
|
|
67
|
+
badge: "bg-violet-400/10 text-violet-400 ring-1 ring-violet-400/25",
|
|
68
|
+
label: "blocked",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const QUEUE_ACCENT = [
|
|
73
|
+
"#f59e0b",
|
|
74
|
+
"#34d399",
|
|
75
|
+
"#60a5fa",
|
|
76
|
+
"#a78bfa",
|
|
77
|
+
"#f87171",
|
|
78
|
+
"#fb923c",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/* ── small reusable components ── */
|
|
82
|
+
function Badge({ state }: { state: EventuallyUIJobState }) {
|
|
83
|
+
const s = STATE_STYLES[state];
|
|
84
|
+
return (
|
|
85
|
+
<span
|
|
86
|
+
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-[10px] font-mono font-medium tracking-wide ${s.badge}`}
|
|
87
|
+
>
|
|
88
|
+
<span
|
|
89
|
+
className={`w-1.5 h-1.5 rounded-full ${s.dot} ${state === "active" ? "animate-pulse" : ""}`}
|
|
90
|
+
/>
|
|
91
|
+
{s.label}
|
|
92
|
+
</span>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Stat({
|
|
97
|
+
label,
|
|
98
|
+
value,
|
|
99
|
+
color,
|
|
100
|
+
}: {
|
|
101
|
+
label: string;
|
|
102
|
+
value: number;
|
|
103
|
+
color: string;
|
|
104
|
+
}) {
|
|
105
|
+
return (
|
|
106
|
+
<div className="flex flex-col gap-1 min-w-0">
|
|
107
|
+
<span className="text-[9px] uppercase tracking-[0.12em] text-zinc-500 font-medium">
|
|
108
|
+
{label}
|
|
109
|
+
</span>
|
|
110
|
+
<span
|
|
111
|
+
className={`text-2xl font-mono font-semibold leading-none ${color}`}
|
|
112
|
+
>
|
|
113
|
+
{value}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ── Config Modal ── */
|
|
120
|
+
function ConfigModal({
|
|
121
|
+
config,
|
|
122
|
+
workerPaused,
|
|
123
|
+
queueName,
|
|
124
|
+
onClose,
|
|
125
|
+
}: {
|
|
126
|
+
config: Record<string, unknown>;
|
|
127
|
+
workerPaused: boolean;
|
|
128
|
+
queueName: string;
|
|
129
|
+
onClose: () => void;
|
|
130
|
+
}) {
|
|
131
|
+
const backdropRef = useRef<HTMLDivElement>(null);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const handler = (e: KeyboardEvent) => {
|
|
135
|
+
if (e.key === "Escape") onClose();
|
|
136
|
+
};
|
|
137
|
+
window.addEventListener("keydown", handler);
|
|
138
|
+
return () => window.removeEventListener("keydown", handler);
|
|
139
|
+
}, [onClose]);
|
|
140
|
+
|
|
141
|
+
const rows: [string, string][] = [
|
|
142
|
+
["Purpose", String(config.description ?? "—")],
|
|
143
|
+
["Concurrency", String(config.concurrency ?? "—")],
|
|
144
|
+
[
|
|
145
|
+
"Poll Interval",
|
|
146
|
+
config.pollIntervalMs != null ? `${config.pollIntervalMs}ms` : "—",
|
|
147
|
+
],
|
|
148
|
+
["Attempts", String(config.attempts ?? "—")],
|
|
149
|
+
["Keep Completed", String(config.removeOnCompleteCount ?? "—")],
|
|
150
|
+
["Keep Failed", String(config.removeOnFailCount ?? "—")],
|
|
151
|
+
["Recurring", config.recurringEnabled ? "enabled" : "off"],
|
|
152
|
+
["Worker", workerPaused ? "paused" : "running"],
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div
|
|
157
|
+
ref={backdropRef}
|
|
158
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
159
|
+
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(4px)" }}
|
|
160
|
+
onClick={(e) => {
|
|
161
|
+
if (e.target === backdropRef.current) onClose();
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
<div className="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden">
|
|
165
|
+
{/* Header */}
|
|
166
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800">
|
|
167
|
+
<div>
|
|
168
|
+
<p className="text-xs uppercase tracking-[0.12em] text-zinc-500 font-medium mb-0.5">
|
|
169
|
+
Configuration
|
|
170
|
+
</p>
|
|
171
|
+
<h2 className="text-sm font-mono font-semibold text-zinc-100">
|
|
172
|
+
{queueName}
|
|
173
|
+
</h2>
|
|
174
|
+
</div>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={onClose}
|
|
178
|
+
className="w-7 h-7 flex items-center justify-center rounded text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
|
|
179
|
+
>
|
|
180
|
+
<svg
|
|
181
|
+
width="14"
|
|
182
|
+
height="14"
|
|
183
|
+
viewBox="0 0 14 14"
|
|
184
|
+
fill="none"
|
|
185
|
+
stroke="currentColor"
|
|
186
|
+
strokeWidth="1.5"
|
|
187
|
+
>
|
|
188
|
+
<path d="M1 1l12 12M13 1L1 13" />
|
|
189
|
+
</svg>
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Grid */}
|
|
194
|
+
<div className="p-5 grid grid-cols-2 gap-3">
|
|
195
|
+
{rows.map(([label, value], i) => (
|
|
196
|
+
<div
|
|
197
|
+
key={label}
|
|
198
|
+
className={`${i === 0 ? "col-span-2" : ""} bg-zinc-800/50 border border-zinc-800 rounded-lg px-4 py-3`}
|
|
199
|
+
>
|
|
200
|
+
<p className="text-[9px] uppercase tracking-[0.12em] text-zinc-500 font-medium mb-1.5">
|
|
201
|
+
{label}
|
|
202
|
+
</p>
|
|
203
|
+
<p className="text-xs font-mono text-zinc-200 leading-relaxed">
|
|
204
|
+
{value}
|
|
205
|
+
</p>
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ── Add Job Modal ── */
|
|
215
|
+
function AddJobModal({
|
|
216
|
+
queueName,
|
|
217
|
+
onClose,
|
|
218
|
+
onSubmit,
|
|
219
|
+
}: {
|
|
220
|
+
queueName: string;
|
|
221
|
+
onClose: () => void;
|
|
222
|
+
onSubmit: (payload: string) => Promise<string | null>;
|
|
223
|
+
}) {
|
|
224
|
+
const [payload, setPayload] = useState("{\n \n}");
|
|
225
|
+
const [error, setError] = useState<string | null>(null);
|
|
226
|
+
const [loading, setLoading] = useState(false);
|
|
227
|
+
const backdropRef = useRef<HTMLDivElement>(null);
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const handler = (e: KeyboardEvent) => {
|
|
231
|
+
if (e.key === "Escape") onClose();
|
|
232
|
+
};
|
|
233
|
+
window.addEventListener("keydown", handler);
|
|
234
|
+
return () => window.removeEventListener("keydown", handler);
|
|
235
|
+
}, [onClose]);
|
|
236
|
+
|
|
237
|
+
async function handleSubmit() {
|
|
238
|
+
setLoading(true);
|
|
239
|
+
const err = await onSubmit(payload);
|
|
240
|
+
setLoading(false);
|
|
241
|
+
if (err) setError(err);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
ref={backdropRef}
|
|
247
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
248
|
+
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(4px)" }}
|
|
249
|
+
onClick={(e) => {
|
|
250
|
+
if (e.target === backdropRef.current) onClose();
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<div className="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden">
|
|
254
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800">
|
|
255
|
+
<div>
|
|
256
|
+
<p className="text-xs uppercase tracking-[0.12em] text-zinc-500 font-medium mb-0.5">
|
|
257
|
+
Enqueue job
|
|
258
|
+
</p>
|
|
259
|
+
<h2 className="text-sm font-mono font-semibold text-zinc-100">
|
|
260
|
+
{queueName}
|
|
261
|
+
</h2>
|
|
262
|
+
</div>
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
onClick={onClose}
|
|
266
|
+
className="w-7 h-7 flex items-center justify-center rounded text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors"
|
|
267
|
+
>
|
|
268
|
+
<svg
|
|
269
|
+
width="14"
|
|
270
|
+
height="14"
|
|
271
|
+
viewBox="0 0 14 14"
|
|
272
|
+
fill="none"
|
|
273
|
+
stroke="currentColor"
|
|
274
|
+
strokeWidth="1.5"
|
|
275
|
+
>
|
|
276
|
+
<path d="M1 1l12 12M13 1L1 13" />
|
|
277
|
+
</svg>
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<div className="p-5 space-y-3">
|
|
282
|
+
<p className="text-xs text-zinc-500">
|
|
283
|
+
Provide a JSON payload for the new job.
|
|
284
|
+
</p>
|
|
285
|
+
<textarea
|
|
286
|
+
className="w-full h-44 resize-y bg-zinc-950 border border-zinc-800 focus:border-zinc-600 rounded-lg text-xs font-mono text-zinc-200 p-3 outline-none transition-colors"
|
|
287
|
+
value={payload}
|
|
288
|
+
onChange={(e) => {
|
|
289
|
+
setPayload(e.target.value);
|
|
290
|
+
setError(null);
|
|
291
|
+
}}
|
|
292
|
+
spellCheck={false}
|
|
293
|
+
/>
|
|
294
|
+
{error && (
|
|
295
|
+
<p className="text-[11px] text-red-400 bg-red-400/8 border border-red-400/20 rounded-lg px-3 py-2">
|
|
296
|
+
{error}
|
|
297
|
+
</p>
|
|
298
|
+
)}
|
|
299
|
+
<div className="flex justify-end gap-2 pt-1">
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
onClick={onClose}
|
|
303
|
+
className="px-4 py-1.5 rounded text-xs font-mono text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 border border-zinc-800 hover:border-zinc-700 transition-colors"
|
|
304
|
+
>
|
|
305
|
+
Cancel
|
|
306
|
+
</button>
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
onClick={() => void handleSubmit()}
|
|
310
|
+
disabled={loading}
|
|
311
|
+
className="px-4 py-1.5 rounded text-xs font-mono font-medium bg-zinc-100 text-zinc-900 hover:bg-white disabled:opacity-40 transition-colors"
|
|
312
|
+
>
|
|
313
|
+
{loading ? "Running…" : "Run job"}
|
|
314
|
+
</button>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ── Main Dashboard ── */
|
|
323
|
+
export function EventuallyDashboard() {
|
|
324
|
+
const { error, pending, perform, snapshot, stale } = useEventuallyUI();
|
|
325
|
+
|
|
326
|
+
const [selectedQueue, setSelectedQueue] = useState<string | null>(null);
|
|
327
|
+
const [sidebarOpen, setSidebarOpen] = useState(
|
|
328
|
+
typeof window !== "undefined" ? window.innerWidth >= 768 : true,
|
|
329
|
+
);
|
|
330
|
+
const [configOpen, setConfigOpen] = useState(false);
|
|
331
|
+
const [addJobOpen, setAddJobOpen] = useState(false);
|
|
332
|
+
const [liveEventsHeight, setLiveEventsHeight] = useState(220);
|
|
333
|
+
const liveEventsResizeRef = useRef<{
|
|
334
|
+
pointerId: number;
|
|
335
|
+
startY: number;
|
|
336
|
+
startHeight: number;
|
|
337
|
+
} | null>(null);
|
|
338
|
+
|
|
339
|
+
/* close sidebar by default on small screens */
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
const mq = window.matchMedia("(max-width: 767px)");
|
|
342
|
+
const handle = (e: MediaQueryListEvent) => setSidebarOpen(!e.matches);
|
|
343
|
+
mq.addEventListener("change", handle);
|
|
344
|
+
return () => mq.removeEventListener("change", handle);
|
|
345
|
+
}, []);
|
|
346
|
+
|
|
347
|
+
/* deselect queue if it disappears */
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
if (!selectedQueue) return;
|
|
350
|
+
if (!snapshot?.queues.some((q) => q.name === selectedQueue))
|
|
351
|
+
setSelectedQueue(null);
|
|
352
|
+
}, [selectedQueue, snapshot]);
|
|
353
|
+
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
356
|
+
const resizeState = liveEventsResizeRef.current;
|
|
357
|
+
if (!resizeState) return;
|
|
358
|
+
|
|
359
|
+
const delta = resizeState.startY - event.clientY;
|
|
360
|
+
const maxHeight = Math.min(
|
|
361
|
+
window.innerHeight * 0.6,
|
|
362
|
+
LIVE_EVENTS_MAX_HEIGHT,
|
|
363
|
+
);
|
|
364
|
+
setLiveEventsHeight(
|
|
365
|
+
Math.round(
|
|
366
|
+
Math.max(
|
|
367
|
+
LIVE_EVENTS_MIN_HEIGHT,
|
|
368
|
+
Math.min(maxHeight, resizeState.startHeight + delta),
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const handlePointerUp = (event: PointerEvent) => {
|
|
375
|
+
if (liveEventsResizeRef.current?.pointerId !== event.pointerId) return;
|
|
376
|
+
liveEventsResizeRef.current = null;
|
|
377
|
+
document.body.style.userSelect = "";
|
|
378
|
+
document.body.style.cursor = "";
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
window.addEventListener("pointermove", handlePointerMove);
|
|
382
|
+
window.addEventListener("pointerup", handlePointerUp);
|
|
383
|
+
|
|
384
|
+
return () => {
|
|
385
|
+
window.removeEventListener("pointermove", handlePointerMove);
|
|
386
|
+
window.removeEventListener("pointerup", handlePointerUp);
|
|
387
|
+
document.body.style.userSelect = "";
|
|
388
|
+
document.body.style.cursor = "";
|
|
389
|
+
};
|
|
390
|
+
}, []);
|
|
391
|
+
|
|
392
|
+
const queue = selectedQueue
|
|
393
|
+
? (snapshot?.queues.find((q) => q.name === selectedQueue) ?? null)
|
|
394
|
+
: null;
|
|
395
|
+
const worker = snapshot?.workers.find((w) => w.queue === queue?.name);
|
|
396
|
+
const queueConfig = queue?.config as Record<string, unknown> | undefined;
|
|
397
|
+
const queueEvents = queue
|
|
398
|
+
? (snapshot?.events ?? []).filter((e) => e.queue === queue.name)
|
|
399
|
+
: [];
|
|
400
|
+
|
|
401
|
+
/* throughput bars (12 slots) */
|
|
402
|
+
const bars = (() => {
|
|
403
|
+
const raw = queueEvents.slice(0, 12).map((ev, i) => {
|
|
404
|
+
const base = 30 + ((queueEvents.length - i) % 6) * 10;
|
|
405
|
+
if (ev.type === "completed") return Math.min(90, base + 22);
|
|
406
|
+
if (ev.type === "active") return Math.min(90, base + 10);
|
|
407
|
+
return Math.max(20, base);
|
|
408
|
+
});
|
|
409
|
+
while (raw.length < 12) raw.push(30 + (raw.length % 5) * 8);
|
|
410
|
+
return raw;
|
|
411
|
+
})();
|
|
412
|
+
|
|
413
|
+
async function handleAddJob(payload: string): Promise<string | null> {
|
|
414
|
+
if (!queue) return null;
|
|
415
|
+
try {
|
|
416
|
+
const parsed = JSON.parse(payload) as unknown;
|
|
417
|
+
await perform(
|
|
418
|
+
{ action: "enqueue-job", queue: queue.name, payload: parsed },
|
|
419
|
+
`run ${queue.name}`,
|
|
420
|
+
);
|
|
421
|
+
setAddJobOpen(false);
|
|
422
|
+
return null;
|
|
423
|
+
} catch (err) {
|
|
424
|
+
return err instanceof Error ? err.message : "Invalid JSON payload";
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function handleLiveEventsResizeStart(
|
|
429
|
+
event: ReactPointerEvent<HTMLButtonElement>,
|
|
430
|
+
) {
|
|
431
|
+
liveEventsResizeRef.current = {
|
|
432
|
+
pointerId: event.pointerId,
|
|
433
|
+
startY: event.clientY,
|
|
434
|
+
startHeight: liveEventsHeight,
|
|
435
|
+
};
|
|
436
|
+
document.body.style.userSelect = "none";
|
|
437
|
+
document.body.style.cursor = "ns-resize";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<div className="flex h-screen bg-zinc-950 text-zinc-300 font-mono overflow-hidden">
|
|
442
|
+
{/* ── Sidebar ── */}
|
|
443
|
+
<aside
|
|
444
|
+
className={`
|
|
445
|
+
flex-shrink-0 flex flex-col border-r border-zinc-800/60 bg-zinc-950
|
|
446
|
+
transition-all duration-200 overflow-hidden
|
|
447
|
+
${sidebarOpen ? "w-52" : "w-0 md:w-12"}
|
|
448
|
+
`}
|
|
449
|
+
>
|
|
450
|
+
{/* Queue list — hidden when collapsed on desktop */}
|
|
451
|
+
{sidebarOpen && (
|
|
452
|
+
<div
|
|
453
|
+
className={`flex flex-col flex-1 min-h-0 ${sidebarOpen ? "opacity-100" : "opacity-0 md:opacity-0 pointer-events-none"} transition-opacity duration-150`}
|
|
454
|
+
>
|
|
455
|
+
<div className="px-4 pt-4 pb-2">
|
|
456
|
+
<p className="text-[9px] uppercase tracking-[0.14em] text-zinc-600 font-medium">
|
|
457
|
+
Queues
|
|
458
|
+
</p>
|
|
459
|
+
</div>
|
|
460
|
+
<nav className="flex flex-col gap-0.5 px-2 overflow-y-auto flex-1 pb-4">
|
|
461
|
+
{(snapshot?.queues ?? []).map((item, idx) => {
|
|
462
|
+
const accent = QUEUE_ACCENT[idx % QUEUE_ACCENT.length];
|
|
463
|
+
const isActive = item.name === queue?.name;
|
|
464
|
+
return (
|
|
465
|
+
<button
|
|
466
|
+
key={item.name}
|
|
467
|
+
type="button"
|
|
468
|
+
onClick={() => {
|
|
469
|
+
setSelectedQueue(item.name);
|
|
470
|
+
if (window.innerWidth < 768) setSidebarOpen(false);
|
|
471
|
+
}}
|
|
472
|
+
className={`
|
|
473
|
+
w-full flex items-center gap-2.5 px-3 py-2 rounded text-left text-xs transition-colors truncate
|
|
474
|
+
${
|
|
475
|
+
isActive
|
|
476
|
+
? "bg-zinc-800 text-zinc-100"
|
|
477
|
+
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-900"
|
|
478
|
+
}
|
|
479
|
+
`}
|
|
480
|
+
>
|
|
481
|
+
<span
|
|
482
|
+
className="w-2 h-2 rounded-full flex-shrink-0"
|
|
483
|
+
style={{ background: accent }}
|
|
484
|
+
/>
|
|
485
|
+
<span className="truncate">{item.name}</span>
|
|
486
|
+
</button>
|
|
487
|
+
);
|
|
488
|
+
})}
|
|
489
|
+
</nav>
|
|
490
|
+
</div>
|
|
491
|
+
)}
|
|
492
|
+
|
|
493
|
+
{/* Collapsed icon state — desktop only */}
|
|
494
|
+
{!sidebarOpen && (
|
|
495
|
+
<div className="hidden md:flex flex-col items-center gap-2 py-4 px-1">
|
|
496
|
+
{(snapshot?.queues ?? []).slice(0, 8).map((item, idx) => {
|
|
497
|
+
const accent = QUEUE_ACCENT[idx % QUEUE_ACCENT.length];
|
|
498
|
+
const isActive = item.name === queue?.name;
|
|
499
|
+
return (
|
|
500
|
+
<button
|
|
501
|
+
key={item.name}
|
|
502
|
+
type="button"
|
|
503
|
+
title={item.name}
|
|
504
|
+
onClick={() => setSelectedQueue(item.name)}
|
|
505
|
+
className={`w-6 h-6 rounded flex items-center justify-center transition-colors ${isActive ? "bg-zinc-800" : "hover:bg-zinc-900"}`}
|
|
506
|
+
>
|
|
507
|
+
<span
|
|
508
|
+
className="w-2 h-2 rounded-full"
|
|
509
|
+
style={{ background: accent }}
|
|
510
|
+
/>
|
|
511
|
+
</button>
|
|
512
|
+
);
|
|
513
|
+
})}
|
|
514
|
+
</div>
|
|
515
|
+
)}
|
|
516
|
+
</aside>
|
|
517
|
+
|
|
518
|
+
{/* ── Main ── */}
|
|
519
|
+
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
|
520
|
+
{/* Top bar */}
|
|
521
|
+
<header className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800/60 flex-shrink-0 bg-zinc-950 z-10">
|
|
522
|
+
{/* Sidebar toggle */}
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
onClick={() => setSidebarOpen((v) => !v)}
|
|
526
|
+
className="w-7 h-7 flex items-center justify-center rounded text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors flex-shrink-0"
|
|
527
|
+
aria-label="Toggle sidebar"
|
|
528
|
+
>
|
|
529
|
+
<svg
|
|
530
|
+
width="14"
|
|
531
|
+
height="14"
|
|
532
|
+
viewBox="0 0 14 14"
|
|
533
|
+
fill="none"
|
|
534
|
+
stroke="currentColor"
|
|
535
|
+
strokeWidth="1.5"
|
|
536
|
+
>
|
|
537
|
+
<path d="M1 2h12M1 7h12M1 12h12" />
|
|
538
|
+
</svg>
|
|
539
|
+
</button>
|
|
540
|
+
|
|
541
|
+
{queue ? (
|
|
542
|
+
<>
|
|
543
|
+
<span className="text-sm font-semibold text-zinc-100 truncate">
|
|
544
|
+
{queue.name}
|
|
545
|
+
</span>
|
|
546
|
+
<Badge state={worker?.paused ? "delayed" : "active"} />
|
|
547
|
+
|
|
548
|
+
<div className="flex items-center gap-2 ml-auto flex-shrink-0">
|
|
549
|
+
{/* Worker pause/resume */}
|
|
550
|
+
<button
|
|
551
|
+
type="button"
|
|
552
|
+
onClick={() =>
|
|
553
|
+
void perform(
|
|
554
|
+
{
|
|
555
|
+
action: worker?.paused
|
|
556
|
+
? "resume-worker"
|
|
557
|
+
: "pause-worker",
|
|
558
|
+
queue: queue.name,
|
|
559
|
+
},
|
|
560
|
+
`${worker?.paused ? "resume" : "pause"} ${queue.name}`,
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
className="px-3 py-1.5 rounded text-[11px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 border border-zinc-800 hover:border-zinc-700 transition-colors"
|
|
564
|
+
>
|
|
565
|
+
{worker?.paused ? "Resume" : "Pause"}
|
|
566
|
+
</button>
|
|
567
|
+
|
|
568
|
+
{/* Config */}
|
|
569
|
+
{queueConfig && (
|
|
570
|
+
<button
|
|
571
|
+
type="button"
|
|
572
|
+
onClick={() => setConfigOpen(true)}
|
|
573
|
+
className="px-3 py-1.5 rounded text-[11px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 border border-zinc-800 hover:border-zinc-700 transition-colors"
|
|
574
|
+
>
|
|
575
|
+
Config
|
|
576
|
+
</button>
|
|
577
|
+
)}
|
|
578
|
+
|
|
579
|
+
{/* Add job */}
|
|
580
|
+
<button
|
|
581
|
+
type="button"
|
|
582
|
+
onClick={() => setAddJobOpen(true)}
|
|
583
|
+
className="px-3 py-1.5 rounded text-[11px] text-zinc-100 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 hover:border-zinc-600 transition-colors font-medium"
|
|
584
|
+
>
|
|
585
|
+
+ Add job
|
|
586
|
+
</button>
|
|
587
|
+
</div>
|
|
588
|
+
|
|
589
|
+
{/* Status string */}
|
|
590
|
+
<span className="hidden lg:block text-[10px] text-zinc-600 flex-shrink-0">
|
|
591
|
+
{pending ? `${pending} pending · ` : ""}
|
|
592
|
+
{queue.jobs.length} jobs · {queueEvents.length} events
|
|
593
|
+
{stale ? " · offline" : ""}
|
|
594
|
+
</span>
|
|
595
|
+
</>
|
|
596
|
+
) : (
|
|
597
|
+
<span className="text-sm text-zinc-600">Select a queue</span>
|
|
598
|
+
)}
|
|
599
|
+
</header>
|
|
600
|
+
|
|
601
|
+
{/* Content */}
|
|
602
|
+
<main className="flex-1 overflow-hidden min-h-0">
|
|
603
|
+
{queue ? (
|
|
604
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
605
|
+
{/* ── Stats row ── */}
|
|
606
|
+
<div className="grid grid-cols-5 border-b border-zinc-800/60 divide-x divide-zinc-800/60">
|
|
607
|
+
<div className="px-5 py-5">
|
|
608
|
+
<Stat
|
|
609
|
+
label="Waiting"
|
|
610
|
+
value={queue.counts.waiting}
|
|
611
|
+
color="text-blue-400"
|
|
612
|
+
/>
|
|
613
|
+
</div>
|
|
614
|
+
<div className="px-5 py-5">
|
|
615
|
+
<Stat
|
|
616
|
+
label="Active"
|
|
617
|
+
value={queue.counts.active}
|
|
618
|
+
color="text-amber-400"
|
|
619
|
+
/>
|
|
620
|
+
</div>
|
|
621
|
+
<div className="px-5 py-5">
|
|
622
|
+
<Stat
|
|
623
|
+
label="Completed"
|
|
624
|
+
value={queue.counts.completed}
|
|
625
|
+
color="text-emerald-400"
|
|
626
|
+
/>
|
|
627
|
+
</div>
|
|
628
|
+
<div className="px-5 py-5">
|
|
629
|
+
<Stat
|
|
630
|
+
label="Failed"
|
|
631
|
+
value={queue.counts.failed}
|
|
632
|
+
color="text-red-400"
|
|
633
|
+
/>
|
|
634
|
+
</div>
|
|
635
|
+
<div className="px-5 py-5">
|
|
636
|
+
<Stat
|
|
637
|
+
label="Delayed"
|
|
638
|
+
value={queue.counts.delayed}
|
|
639
|
+
color="text-violet-400"
|
|
640
|
+
/>
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
|
|
644
|
+
{/* ── Throughput chart ── */}
|
|
645
|
+
<div className="px-5 pt-5 pb-4 border-b border-zinc-800/60">
|
|
646
|
+
<div className="flex items-center justify-between mb-3">
|
|
647
|
+
<span className="text-[9px] uppercase tracking-[0.14em] text-zinc-600 font-medium">
|
|
648
|
+
Throughput · last 12 min
|
|
649
|
+
</span>
|
|
650
|
+
<span className="text-[10px] text-zinc-500">
|
|
651
|
+
avg {Math.max(1, Math.round(queueEvents.length / 3))}{" "}
|
|
652
|
+
jobs/min
|
|
653
|
+
</span>
|
|
654
|
+
</div>
|
|
655
|
+
<div className="flex items-end gap-1 h-12">
|
|
656
|
+
{bars.map((h, i) => (
|
|
657
|
+
<div
|
|
658
|
+
key={i}
|
|
659
|
+
className="flex-1 bg-zinc-700 rounded-sm"
|
|
660
|
+
style={{
|
|
661
|
+
height: `${h}%`,
|
|
662
|
+
opacity: 0.5 + (i / bars.length) * 0.5,
|
|
663
|
+
}}
|
|
664
|
+
/>
|
|
665
|
+
))}
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
|
|
669
|
+
{/* ── Jobs table ── */}
|
|
670
|
+
<div className="flex flex-1 min-h-0 flex-col px-0">
|
|
671
|
+
<div className="flex flex-shrink-0 items-center px-5 py-3 border-b border-zinc-800/60">
|
|
672
|
+
<span className="text-[9px] uppercase tracking-[0.14em] text-zinc-600 font-medium">
|
|
673
|
+
Jobs · {queue.jobs.length}
|
|
674
|
+
</span>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
678
|
+
<table className="w-full text-xs">
|
|
679
|
+
<thead>
|
|
680
|
+
<tr className="border-b border-zinc-800/60">
|
|
681
|
+
{[
|
|
682
|
+
"ID",
|
|
683
|
+
"Name",
|
|
684
|
+
"Status",
|
|
685
|
+
"Attempts",
|
|
686
|
+
"Age",
|
|
687
|
+
"Duration",
|
|
688
|
+
].map((h) => (
|
|
689
|
+
<th
|
|
690
|
+
key={h}
|
|
691
|
+
className="px-5 py-2.5 text-left text-[9px] uppercase tracking-[0.14em] text-zinc-600 font-medium whitespace-nowrap"
|
|
692
|
+
>
|
|
693
|
+
{h}
|
|
694
|
+
</th>
|
|
695
|
+
))}
|
|
696
|
+
</tr>
|
|
697
|
+
</thead>
|
|
698
|
+
<tbody className="divide-y divide-zinc-800/40">
|
|
699
|
+
{queue.jobs
|
|
700
|
+
.slice()
|
|
701
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
702
|
+
.slice(0, 10)
|
|
703
|
+
.map((job) => (
|
|
704
|
+
<tr
|
|
705
|
+
key={job.id}
|
|
706
|
+
className="group hover:bg-zinc-900/50 transition-colors"
|
|
707
|
+
>
|
|
708
|
+
<td className="px-5 py-3 text-zinc-600 font-mono">
|
|
709
|
+
{job.id.slice(0, 8)}
|
|
710
|
+
</td>
|
|
711
|
+
<td className="px-5 py-3 text-zinc-200 font-medium">
|
|
712
|
+
{job.name}
|
|
713
|
+
</td>
|
|
714
|
+
<td className="px-5 py-3">
|
|
715
|
+
<Badge state={job.state} />
|
|
716
|
+
</td>
|
|
717
|
+
<td className="px-5 py-3 text-zinc-500">
|
|
718
|
+
{job.attempt}/{job.attempts}
|
|
719
|
+
</td>
|
|
720
|
+
<td className="px-5 py-3 text-zinc-500 whitespace-nowrap">
|
|
721
|
+
{formatRelative(job.createdAt)}
|
|
722
|
+
</td>
|
|
723
|
+
<td className="px-5 py-3 text-zinc-500 whitespace-nowrap">
|
|
724
|
+
{formatDuration(job)}
|
|
725
|
+
</td>
|
|
726
|
+
</tr>
|
|
727
|
+
))}
|
|
728
|
+
</tbody>
|
|
729
|
+
</table>
|
|
730
|
+
{queue.jobs.length === 0 && (
|
|
731
|
+
<div className="px-5 py-10 text-center text-xs text-zinc-700">
|
|
732
|
+
No jobs in queue
|
|
733
|
+
</div>
|
|
734
|
+
)}
|
|
735
|
+
</div>
|
|
736
|
+
</div>
|
|
737
|
+
|
|
738
|
+
{/* ── Live events ── */}
|
|
739
|
+
<div
|
|
740
|
+
className="border-t border-zinc-800/60 flex-shrink-0 min-h-0"
|
|
741
|
+
style={{ height: liveEventsHeight }}
|
|
742
|
+
>
|
|
743
|
+
<button
|
|
744
|
+
type="button"
|
|
745
|
+
onPointerDown={handleLiveEventsResizeStart}
|
|
746
|
+
className="group relative flex h-3 w-full items-center justify-center touch-none"
|
|
747
|
+
aria-label="Resize live events panel"
|
|
748
|
+
>
|
|
749
|
+
<span className="h-px w-full bg-zinc-800/60 transition-colors group-hover:bg-zinc-700" />
|
|
750
|
+
<span className="absolute rounded-full border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[9px] uppercase tracking-[0.14em] text-zinc-500 opacity-0 transition-opacity group-hover:opacity-100">
|
|
751
|
+
Resize
|
|
752
|
+
</span>
|
|
753
|
+
</button>
|
|
754
|
+
|
|
755
|
+
<div className="flex h-[calc(100%-0.75rem)] flex-col px-5 pb-4">
|
|
756
|
+
<p className="text-[9px] uppercase tracking-[0.14em] text-zinc-600 font-medium mb-3">
|
|
757
|
+
Live Events
|
|
758
|
+
</p>
|
|
759
|
+
{queueEvents.length === 0 ? (
|
|
760
|
+
<p className="text-xs text-zinc-700">No recent events</p>
|
|
761
|
+
) : (
|
|
762
|
+
<div className="flex min-h-0 flex-col gap-1 overflow-y-auto pr-1">
|
|
763
|
+
{queueEvents.map((ev) => (
|
|
764
|
+
<div
|
|
765
|
+
key={ev.id}
|
|
766
|
+
className="grid text-[11px] py-2 px-3 rounded bg-zinc-900 border border-zinc-800/60 hover:border-zinc-700 transition-colors"
|
|
767
|
+
style={{ gridTemplateColumns: "120px 80px 1fr" }}
|
|
768
|
+
>
|
|
769
|
+
<span className="text-zinc-300 font-medium truncate">
|
|
770
|
+
{ev.queue}
|
|
771
|
+
</span>
|
|
772
|
+
<span className="text-zinc-500 uppercase text-[9px] tracking-wider flex items-center">
|
|
773
|
+
{ev.type}
|
|
774
|
+
</span>
|
|
775
|
+
<span className="text-zinc-600 truncate">
|
|
776
|
+
{ev.detail}
|
|
777
|
+
</span>
|
|
778
|
+
</div>
|
|
779
|
+
))}
|
|
780
|
+
</div>
|
|
781
|
+
)}
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
) : (
|
|
786
|
+
/* ── Empty state ── */
|
|
787
|
+
<div className="flex flex-col items-center justify-center h-full text-center px-8 py-16">
|
|
788
|
+
<div className="w-10 h-10 rounded-xl border border-zinc-800 flex items-center justify-center mb-5">
|
|
789
|
+
<svg
|
|
790
|
+
width="18"
|
|
791
|
+
height="18"
|
|
792
|
+
viewBox="0 0 18 18"
|
|
793
|
+
fill="none"
|
|
794
|
+
stroke="currentColor"
|
|
795
|
+
strokeWidth="1.25"
|
|
796
|
+
className="text-zinc-600"
|
|
797
|
+
>
|
|
798
|
+
<rect x="2" y="2" width="14" height="14" rx="2" />
|
|
799
|
+
<path d="M6 9h6M9 6v6" />
|
|
800
|
+
</svg>
|
|
801
|
+
</div>
|
|
802
|
+
<h3 className="text-sm font-semibold text-zinc-400 mb-2">
|
|
803
|
+
Select a queue
|
|
804
|
+
</h3>
|
|
805
|
+
<p className="text-xs text-zinc-700 max-w-xs leading-relaxed">
|
|
806
|
+
{snapshot?.queues.length
|
|
807
|
+
? `${snapshot.queues.length} queue${snapshot.queues.length !== 1 ? "s" : ""} available · ${snapshot.queues.reduce((n, q) => n + q.jobs.length, 0)} total jobs`
|
|
808
|
+
: "Choose a queue from the sidebar to inspect jobs, metrics, and run payloads."}
|
|
809
|
+
</p>
|
|
810
|
+
{stale && <p className="mt-3 text-xs text-red-500/70">{error}</p>}
|
|
811
|
+
</div>
|
|
812
|
+
)}
|
|
813
|
+
</main>
|
|
814
|
+
</div>
|
|
815
|
+
|
|
816
|
+
{/* ── Modals ── */}
|
|
817
|
+
{configOpen && queueConfig && queue && (
|
|
818
|
+
<ConfigModal
|
|
819
|
+
config={queueConfig}
|
|
820
|
+
workerPaused={!!worker?.paused}
|
|
821
|
+
queueName={queue.name}
|
|
822
|
+
onClose={() => setConfigOpen(false)}
|
|
823
|
+
/>
|
|
824
|
+
)}
|
|
825
|
+
|
|
826
|
+
{addJobOpen && queue && (
|
|
827
|
+
<AddJobModal
|
|
828
|
+
queueName={queue.name}
|
|
829
|
+
onClose={() => setAddJobOpen(false)}
|
|
830
|
+
onSubmit={handleAddJob}
|
|
831
|
+
/>
|
|
832
|
+
)}
|
|
833
|
+
</div>
|
|
834
|
+
);
|
|
835
|
+
}
|