@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.
@@ -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
+ }