@visns-studio/visns-components 6.25.0 → 6.27.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,1043 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import {
9
+ Activity,
10
+ AlertTriangle,
11
+ ClipboardCopy,
12
+ Loader2,
13
+ RefreshCw,
14
+ Send,
15
+ TestTube2,
16
+ } from 'lucide-react';
17
+ import { toast } from 'react-toastify';
18
+
19
+ import CustomFetch from '../Fetch';
20
+ import styles from '../styles/CallQueueDiagnostics.module.scss';
21
+
22
+ import { CALL_POP_STATUS_EVENT, getCallPopStatus } from './callPopStatus';
23
+
24
+ /**
25
+ * Call pop diagnostics.
26
+ * -----------------------------------------------------------------------
27
+ * Answers the one question staff keep asking — "why did the pop not show?" —
28
+ * by putting the two halves of the pipeline side by side:
29
+ *
30
+ * THIS BROWSER what CallQueuePop's status store recorded: permission, Echo,
31
+ * socket state, channel authorisation, events received.
32
+ * SERVER what the webhook ledger recorded: every Zoom event that
33
+ * arrived, what was done with it, and whether the broadcast
34
+ * went out.
35
+ *
36
+ * Read together they localise the fault in one glance: a green ledger row with
37
+ * nothing in the browser is a socket problem; an empty ledger during a real
38
+ * call is a Zoom/webhook problem; a broadcast failure is Reverb being down.
39
+ *
40
+ * Everything degrades: with no server endpoint the browser half still renders,
41
+ * and with no pop mounted yet the browser half renders as "unknown" rather
42
+ * than blank.
43
+ */
44
+
45
+ /** Endpoints the panel talks to; the `endpoints` prop is merged over these. */
46
+ const DEFAULT_ENDPOINTS = {
47
+ diagnostics: '/ajax/call-queue/diagnostics',
48
+ ping: '/ajax/call-queue/diagnostics/ping',
49
+ };
50
+
51
+ /** How long the round-trip test waits for the ping to come back over the socket. */
52
+ const PING_TIMEOUT_MS = 8000;
53
+
54
+ /** Auto-refresh interval, when the toggle is switched on. */
55
+ const REFRESH_MS = 10000;
56
+
57
+ /** Outcomes that mean the pipeline did its job. */
58
+ const OK_OUTCOMES = [
59
+ 'ringing_recorded',
60
+ // A call ringing one staff member's own extension, recorded and popped
61
+ // like any queue call.
62
+ 'ringing_recorded_direct',
63
+ 'recorded',
64
+ 'broadcast',
65
+ ];
66
+
67
+ /** Outcomes that are a deliberate no-op rather than a fault. */
68
+ const WARN_OUTCOMES = [
69
+ 'ringing_unmatched',
70
+ 'ringing_excluded_queue',
71
+ // Direct calls are switched off in settings, so this one was dropped.
72
+ 'ringing_excluded_direct',
73
+ 'ringing_no_call_id',
74
+ 'closed_no_call_id',
75
+ 'closed_no_match',
76
+ // A leg was declined for a call the ledger has never seen ring.
77
+ 'missed_no_match',
78
+ ];
79
+
80
+ /**
81
+ * Outcomes that are neither good news nor bad: they happened, and the panel
82
+ * should say so without colouring them either way. `.queue.missed` is the
83
+ * archetype — one leg of a call that may well still be ringing.
84
+ */
85
+ const MUTED_OUTCOMES = ['missed'];
86
+
87
+ /** Outcomes that are a fault. */
88
+ const BAD_OUTCOMES = ['failed', 'rejected', 'broadcast_failed'];
89
+
90
+ /**
91
+ * CustomFetch already carries the app's credentials and CSRF header (the
92
+ * `X-CSRF-TOKEN`/`X-XSRF-TOKEN` pair read off the `csrf-token` meta tag), which
93
+ * is what CallQueueSettings saves through — so the default fetcher is that same
94
+ * wrapper rather than a second, subtly different, hand-rolled `fetch`.
95
+ *
96
+ * A `fetcher` prop with the same `(url, method, body) => Promise` signature
97
+ * replaces it wholesale, which is how tests and any non-Laravel host drive
98
+ * this panel.
99
+ */
100
+ const defaultFetcher = (url, method, body) => CustomFetch(url, method, body);
101
+
102
+ /** CustomFetch resolves an axios-shaped object; a plain fetcher may not. */
103
+ const unwrap = (result) => {
104
+ if (result && typeof result === 'object' && 'data' in result) {
105
+ return result.data ?? {};
106
+ }
107
+
108
+ return result ?? {};
109
+ };
110
+
111
+ /** A short, collision-proof-enough id for one round trip. */
112
+ const makeNonce = () => {
113
+ const random = Math.random().toString(36).slice(2, 10);
114
+
115
+ return `${Date.now().toString(36)}-${random}`;
116
+ };
117
+
118
+ /** Milliseconds between two ISO/epoch instants, or null when unknowable. */
119
+ const msBetween = (from, to) => {
120
+ const start = typeof from === 'number' ? from : Date.parse(from);
121
+ const end = typeof to === 'number' ? to : Date.parse(to);
122
+
123
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
124
+ return null;
125
+ }
126
+
127
+ return Math.max(0, end - start);
128
+ };
129
+
130
+ const pad = (value) => String(value).padStart(2, '0');
131
+
132
+ /**
133
+ * Local `HH:mm:ss`, prefixed with `DD/MM` when the instant is not today —
134
+ * a ledger read at 9am should not make yesterday's 5pm row look like this
135
+ * morning's.
136
+ */
137
+ export const formatLedgerTime = (value, reference = new Date()) => {
138
+ if (!value) return '—';
139
+
140
+ const at = value instanceof Date ? value : new Date(value);
141
+
142
+ if (Number.isNaN(at.getTime())) return '—';
143
+
144
+ const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
145
+ const sameDay =
146
+ at.getFullYear() === reference.getFullYear() &&
147
+ at.getMonth() === reference.getMonth() &&
148
+ at.getDate() === reference.getDate();
149
+
150
+ return sameDay
151
+ ? clock
152
+ : `${pad(at.getDate())}/${pad(at.getMonth() + 1)} ${clock}`;
153
+ };
154
+
155
+ /** `phone.callee_ringing` -> `callee_ringing`. The prefix is on every row. */
156
+ export const shortEventName = (name) =>
157
+ String(name ?? '').replace(/^phone\./, '') || '—';
158
+
159
+ /** Which tone a ledger outcome earns. */
160
+ export const outcomeTone = (outcome, broadcastFailed = false) => {
161
+ const value = String(outcome ?? '').toLowerCase();
162
+
163
+ if (broadcastFailed || BAD_OUTCOMES.includes(value)) return 'bad';
164
+ if (WARN_OUTCOMES.includes(value)) return 'warn';
165
+ if (OK_OUTCOMES.includes(value)) return 'ok';
166
+ if (MUTED_OUTCOMES.includes(value)) return 'muted';
167
+
168
+ // An outcome nobody has classified yet reads the same as a deliberately
169
+ // neutral one: grey, and no claim either way about what it means.
170
+ return 'muted';
171
+ };
172
+
173
+ /**
174
+ * The ledger's `broadcast` column is a word, not a flag: 'ok' when the publish
175
+ * landed, 'failed' when it threw, and null for the deliveries that never got as
176
+ * far as broadcasting anything. A boolean is accepted too, in case a host
177
+ * serialises it that way.
178
+ */
179
+ export const broadcastState = (value) => {
180
+ if (value === true) return 'ok';
181
+ if (value === false) return 'failed';
182
+
183
+ const word = String(value ?? '').toLowerCase();
184
+
185
+ if (word === 'ok' || word === 'sent' || word === 'true') return 'ok';
186
+ if (word === 'failed' || word === 'false') return 'failed';
187
+
188
+ return null;
189
+ };
190
+
191
+ /** Zoom call ids are long and only the tail distinguishes them. */
192
+ export const shortCallId = (callId) => {
193
+ const value = String(callId ?? '');
194
+
195
+ return value.length > 8 ? value.slice(-8) : value || '—';
196
+ };
197
+
198
+ /** Cut a string for display; the full text goes in the cell's title. */
199
+ export const truncate = (value, length = 60) => {
200
+ const text = String(value ?? '');
201
+
202
+ return text.length > length ? `${text.slice(0, length - 1)}…` : text;
203
+ };
204
+
205
+ /** Socket state -> dot colour. Green only when it is actually connected. */
206
+ export const connectionTone = (state) => {
207
+ if (state === 'connected') return 'ok';
208
+ if (state === 'connecting' || state === 'initialized') return 'warn';
209
+ if (!state) return 'muted';
210
+
211
+ return 'bad';
212
+ };
213
+
214
+ const yesNo = (value, unknown = 'unknown') => {
215
+ if (value === true) return 'Yes';
216
+ if (value === false) return 'No';
217
+
218
+ return unknown;
219
+ };
220
+
221
+ /** Read a value under either its snake_case or camelCase name. */
222
+ const pick = (source, ...keys) => {
223
+ for (const key of keys) {
224
+ const value = source?.[key];
225
+
226
+ if (value !== undefined && value !== null) return value;
227
+ }
228
+
229
+ return null;
230
+ };
231
+
232
+ const CallQueueDiagnostics = ({
233
+ endpoints,
234
+ fetcher = defaultFetcher,
235
+ title = 'Call pop diagnostics',
236
+ embedded = false,
237
+ }) => {
238
+ const [status, setStatus] = useState(() => getCallPopStatus());
239
+ const [server, setServer] = useState(null);
240
+ const [isLoading, setIsLoading] = useState(true);
241
+ const [loadError, setLoadError] = useState('');
242
+ const [autoRefresh, setAutoRefresh] = useState(false);
243
+ const [ping, setPing] = useState({ state: 'idle' });
244
+
245
+ // The ping's own bookkeeping, read inside a timeout that must not re-arm
246
+ // every time the status store ticks.
247
+ const pingTimer = useRef(null);
248
+
249
+ const diagnosticsUrl =
250
+ endpoints?.diagnostics ?? DEFAULT_ENDPOINTS.diagnostics;
251
+ const pingUrl = endpoints?.ping ?? DEFAULT_ENDPOINTS.ping;
252
+
253
+ /** Live browser status: seed from the store, then follow its event. */
254
+ useEffect(() => {
255
+ if (typeof window === 'undefined') {
256
+ return undefined;
257
+ }
258
+
259
+ const onStatus = (event) => {
260
+ setStatus(event?.detail ?? getCallPopStatus());
261
+ };
262
+
263
+ window.addEventListener(CALL_POP_STATUS_EVENT, onStatus);
264
+ setStatus(getCallPopStatus());
265
+
266
+ return () => {
267
+ window.removeEventListener(CALL_POP_STATUS_EVENT, onStatus);
268
+ };
269
+ }, []);
270
+
271
+ const load = useCallback(async () => {
272
+ setIsLoading(true);
273
+
274
+ try {
275
+ const data = unwrap(await fetcher(diagnosticsUrl, 'GET', {}));
276
+
277
+ setServer(data ?? {});
278
+ setLoadError('');
279
+ } catch (error) {
280
+ setLoadError(
281
+ String(
282
+ error?.message ??
283
+ error ??
284
+ 'The diagnostics endpoint could not be reached.'
285
+ )
286
+ );
287
+ } finally {
288
+ setIsLoading(false);
289
+ }
290
+ }, [diagnosticsUrl, fetcher]);
291
+
292
+ useEffect(() => {
293
+ load();
294
+ }, [load]);
295
+
296
+ useEffect(() => {
297
+ if (!autoRefresh) {
298
+ return undefined;
299
+ }
300
+
301
+ const interval = setInterval(() => {
302
+ load();
303
+ }, REFRESH_MS);
304
+
305
+ return () => clearInterval(interval);
306
+ }, [autoRefresh, load]);
307
+
308
+ /** Give up on a round trip that never came back over the socket. */
309
+ useEffect(
310
+ () => () => {
311
+ if (pingTimer.current) {
312
+ clearTimeout(pingTimer.current);
313
+ pingTimer.current = null;
314
+ }
315
+ },
316
+ []
317
+ );
318
+
319
+ /**
320
+ * The other half of the round trip: the pop's status store records the
321
+ * `.queue.diagnostic-ping` it received, and a matching nonce is proof that
322
+ * this browser is being delivered to.
323
+ */
324
+ useEffect(() => {
325
+ if (ping.state !== 'waiting' || !ping.nonce) {
326
+ return;
327
+ }
328
+
329
+ const received = status.lastPing;
330
+
331
+ if (!received || String(received.nonce) !== String(ping.nonce)) {
332
+ return;
333
+ }
334
+
335
+ if (pingTimer.current) {
336
+ clearTimeout(pingTimer.current);
337
+ pingTimer.current = null;
338
+ }
339
+
340
+ setPing((previous) => ({
341
+ ...previous,
342
+ state: 'received',
343
+ roundTripMs: msBetween(previous.sentAt, received.receivedAt),
344
+ }));
345
+ }, [ping.nonce, ping.state, status.lastPing]);
346
+
347
+ const sendPing = useCallback(async () => {
348
+ const nonce = makeNonce();
349
+ const sentAt = Date.now();
350
+
351
+ if (pingTimer.current) {
352
+ clearTimeout(pingTimer.current);
353
+ pingTimer.current = null;
354
+ }
355
+
356
+ setPing({ state: 'sending', nonce, sentAt });
357
+
358
+ try {
359
+ const data = unwrap(await fetcher(pingUrl, 'POST', { nonce }));
360
+ // The server may mint its own nonce; if it does, that is the one
361
+ // the broadcast will carry.
362
+ const echoed = pick(data, 'nonce') ?? nonce;
363
+ const ok = data?.ok !== false && !data?.error;
364
+
365
+ setPing({
366
+ state: ok ? 'waiting' : 'failed',
367
+ nonce: String(echoed),
368
+ sentAt,
369
+ serverMs: pick(data, 'ms', 'duration_ms', 'durationMs'),
370
+ error: data?.error ? String(data.error) : '',
371
+ });
372
+
373
+ if (!ok) {
374
+ return;
375
+ }
376
+
377
+ pingTimer.current = setTimeout(() => {
378
+ pingTimer.current = null;
379
+ setPing((previous) =>
380
+ previous.state === 'waiting'
381
+ ? { ...previous, state: 'timeout' }
382
+ : previous
383
+ );
384
+ }, PING_TIMEOUT_MS);
385
+ } catch (error) {
386
+ setPing({
387
+ state: 'failed',
388
+ nonce,
389
+ sentAt,
390
+ error: String(
391
+ error?.message ?? error ?? 'The ping could not be sent.'
392
+ ),
393
+ });
394
+ }
395
+ }, [fetcher, pingUrl]);
396
+
397
+ const copyStatus = useCallback(() => {
398
+ const text = JSON.stringify(getCallPopStatus(), null, 2);
399
+
400
+ try {
401
+ if (navigator?.clipboard?.writeText) {
402
+ navigator.clipboard
403
+ .writeText(text)
404
+ .then(() => toast.success('Call pop status copied.'))
405
+ .catch(() => toast.error('Could not copy the status.'));
406
+
407
+ return;
408
+ }
409
+ } catch (error) {
410
+ // Falls through to the console below.
411
+ }
412
+
413
+ console.info('[call-pop] status', text);
414
+ toast.info(
415
+ 'Clipboard unavailable — the status was logged to the console.'
416
+ );
417
+ }, []);
418
+
419
+ const showDemo = useCallback(() => {
420
+ if (
421
+ typeof window === 'undefined' ||
422
+ typeof window.callPopDemo !== 'function'
423
+ ) {
424
+ toast.error('The call pop is not mounted on this page.');
425
+
426
+ return;
427
+ }
428
+
429
+ window.callPopDemo();
430
+ }, []);
431
+
432
+ const serverBlock = server?.server ?? {};
433
+ const summary = server?.summary ?? {};
434
+ const events = Array.isArray(server?.events) ? server.events : [];
435
+
436
+ const serverChannel = pick(serverBlock, 'channel');
437
+ const channelMismatch = Boolean(
438
+ serverChannel && status.channel && serverChannel !== status.channel
439
+ );
440
+
441
+ const broadcastFailures = Number(
442
+ pick(summary, 'broadcast_failures_24h', 'broadcastFailures24h') ?? 0
443
+ );
444
+
445
+ const last24h = summary?.last_24h ?? summary?.last24h ?? {};
446
+ const last7d = summary?.last_7d ?? summary?.last7d ?? {};
447
+
448
+ const socketTone = connectionTone(status.connectionState);
449
+
450
+ const pingLine = useMemo(() => {
451
+ switch (ping.state) {
452
+ case 'sending':
453
+ return 'Sending…';
454
+ case 'waiting':
455
+ return `Server accepted the ping${
456
+ ping.serverMs ? ` (${ping.serverMs} ms)` : ''
457
+ } — waiting for it to arrive over the socket…`;
458
+ case 'received':
459
+ return `Received in browser after ${ping.roundTripMs ?? '?'} ms.`;
460
+ case 'timeout':
461
+ return 'Not received — the socket is not delivering to this browser.';
462
+ case 'failed':
463
+ return `Ping failed: ${ping.error || 'the server refused it'}.`;
464
+ default:
465
+ return '';
466
+ }
467
+ }, [ping]);
468
+
469
+ const pingTone =
470
+ ping.state === 'received'
471
+ ? 'ok'
472
+ : ping.state === 'timeout' || ping.state === 'failed'
473
+ ? 'bad'
474
+ : ping.state === 'idle'
475
+ ? 'muted'
476
+ : 'warn';
477
+
478
+ const tone = (name) =>
479
+ name === 'ok'
480
+ ? styles.isOk
481
+ : name === 'warn'
482
+ ? styles.isWarn
483
+ : name === 'bad'
484
+ ? styles.isBad
485
+ : styles.isMuted;
486
+
487
+ const row = (label, value, toneName) => (
488
+ <div className={styles.cqdiagRow}>
489
+ <span className={styles.cqdiagLabel}>{label}</span>
490
+ <span
491
+ className={`${styles.cqdiagValue} ${toneName ? tone(toneName) : ''}`}
492
+ >
493
+ {value}
494
+ </span>
495
+ </div>
496
+ );
497
+
498
+ const countChips = (bucket) => {
499
+ const entries = Object.entries(bucket ?? {}).filter(
500
+ ([, count]) => Number(count) > 0
501
+ );
502
+
503
+ if (entries.length === 0) {
504
+ return <span className={styles.cqdiagNone}>nothing recorded</span>;
505
+ }
506
+
507
+ return entries.map(([name, count]) => (
508
+ <span
509
+ key={name}
510
+ className={`${styles.cqdiagChip} ${tone(outcomeTone(name))}`}
511
+ >
512
+ {shortEventName(name)} {count}
513
+ </span>
514
+ ));
515
+ };
516
+
517
+ return (
518
+ <div className={styles.cqdiag}>
519
+ {embedded || !title ? null : (
520
+ <h2 className={styles.cqdiagTitle}>{title}</h2>
521
+ )}
522
+
523
+ <div className={styles.cqdiagColumns}>
524
+ {/* --- this browser -------------------------------------- */}
525
+ <section className={styles.cqdiagSection}>
526
+ <h3 className={styles.cqdiagHeading}>
527
+ <Activity size={14} />
528
+ <span>This browser</span>
529
+ </h3>
530
+
531
+ {row(
532
+ 'Monitor permission',
533
+ yesNo(status.canMonitor),
534
+ status.canMonitor === false ? 'bad' : 'ok'
535
+ )}
536
+ {row(
537
+ 'Echo created',
538
+ yesNo(status.echoAvailable, 'not yet'),
539
+ status.echoAvailable === false ? 'bad' : undefined
540
+ )}
541
+ {row(
542
+ 'Socket',
543
+ <>
544
+ <span
545
+ className={`${styles.cqdiagDot} ${tone(socketTone)}`}
546
+ />
547
+ {status.connectionState ?? 'unknown'}
548
+ </>
549
+ )}
550
+ {row('Channel', status.channel ?? '—')}
551
+ {row(
552
+ 'Subscribed',
553
+ status.subscriptionError
554
+ ? `No — ${status.subscriptionError.status ?? 'error'}: ${
555
+ status.subscriptionError.message
556
+ }`
557
+ : yesNo(status.subscribed),
558
+ status.subscriptionError || status.subscribed === false
559
+ ? 'bad'
560
+ : 'ok'
561
+ )}
562
+ {row(
563
+ 'Last event',
564
+ status.lastEventName
565
+ ? `${shortEventName(status.lastEventName)} at ${formatLedgerTime(
566
+ status.lastEventAt
567
+ )}`
568
+ : 'none yet'
569
+ )}
570
+ {row('Events received', status.eventsReceived)}
571
+ {row(
572
+ 'Socket drops',
573
+ status.disconnects,
574
+ status.disconnects > 0 ? 'warn' : undefined
575
+ )}
576
+ {row(
577
+ 'Snapshot',
578
+ status.snapshotError
579
+ ? `failed — ${status.snapshotError}`
580
+ : status.snapshotAt
581
+ ? `${formatLedgerTime(status.snapshotAt)} · ${
582
+ status.snapshotCalls
583
+ } call(s)`
584
+ : 'not fetched',
585
+ status.snapshotError ? 'bad' : undefined
586
+ )}
587
+ {row('Pop showing', `${status.visibleCalls} call(s)`)}
588
+
589
+ <div className={styles.cqdiagActions}>
590
+ <button
591
+ type="button"
592
+ className={styles.cqdiagButton}
593
+ onClick={showDemo}
594
+ >
595
+ <TestTube2 size={13} />
596
+ <span>Show demo pop</span>
597
+ </button>
598
+ <button
599
+ type="button"
600
+ className={styles.cqdiagButton}
601
+ onClick={copyStatus}
602
+ >
603
+ <ClipboardCopy size={13} />
604
+ <span>Copy status</span>
605
+ </button>
606
+ </div>
607
+ </section>
608
+
609
+ {/* --- server -------------------------------------------- */}
610
+ <section className={styles.cqdiagSection}>
611
+ <h3 className={styles.cqdiagHeading}>
612
+ <RefreshCw size={14} />
613
+ <span>Server</span>
614
+ </h3>
615
+
616
+ {loadError ? (
617
+ <div className={styles.cqdiagWarning}>
618
+ <AlertTriangle size={14} />
619
+ <span>
620
+ The diagnostics endpoint could not be read (
621
+ {loadError}). Everything on the left is still
622
+ live.
623
+ </span>
624
+ </div>
625
+ ) : null}
626
+
627
+ {row(
628
+ 'Environment',
629
+ pick(serverBlock, 'app_env', 'appEnv') ?? '—'
630
+ )}
631
+ {row(
632
+ 'Broadcast driver',
633
+ pick(
634
+ serverBlock,
635
+ 'broadcast_driver',
636
+ 'broadcastDriver'
637
+ ) ?? '—'
638
+ )}
639
+ {row(
640
+ 'Publish target',
641
+ pick(serverBlock, 'publish_target', 'publishTarget') ??
642
+ '—'
643
+ )}
644
+ {row(
645
+ 'Channel',
646
+ channelMismatch
647
+ ? `${serverChannel} — this browser is on ${status.channel}`
648
+ : (serverChannel ?? '—'),
649
+ channelMismatch ? 'bad' : undefined
650
+ )}
651
+ {row(
652
+ 'Webhook secret',
653
+ yesNo(
654
+ pick(
655
+ serverBlock,
656
+ 'webhook_secret_configured',
657
+ 'webhookSecretConfigured'
658
+ )
659
+ ),
660
+ pick(
661
+ serverBlock,
662
+ 'webhook_secret_configured',
663
+ 'webhookSecretConfigured'
664
+ ) === false
665
+ ? 'bad'
666
+ : undefined
667
+ )}
668
+ {row(
669
+ 'Log level',
670
+ pick(serverBlock, 'log_level', 'logLevel') ?? '—'
671
+ )}
672
+ {row(
673
+ 'Queue connection',
674
+ pick(
675
+ serverBlock,
676
+ 'queue_connection',
677
+ 'queueConnection'
678
+ ) ?? '—'
679
+ )}
680
+ {row(
681
+ 'Live rows',
682
+ pick(serverBlock, 'live_rows', 'liveRows') ?? '—'
683
+ )}
684
+ {row(
685
+ 'Excluded queues',
686
+ (() => {
687
+ // The server sends the ids themselves; the count is
688
+ // what a reader wants, the ids are the tooltip.
689
+ const excluded = pick(
690
+ serverBlock,
691
+ 'excluded_queue_ids',
692
+ 'excludedQueueIds',
693
+ 'excluded_queues',
694
+ 'excludedQueues'
695
+ );
696
+
697
+ if (Array.isArray(excluded)) {
698
+ return (
699
+ <span title={excluded.join(', ')}>
700
+ {excluded.length}
701
+ </span>
702
+ );
703
+ }
704
+
705
+ return excluded ?? '—';
706
+ })()
707
+ )}
708
+ {row(
709
+ 'Stale after',
710
+ (() => {
711
+ const minutes = pick(
712
+ serverBlock,
713
+ 'stale_after_minutes',
714
+ 'staleAfterMinutes'
715
+ );
716
+
717
+ return minutes === null
718
+ ? '—'
719
+ : `${minutes} minute(s)`;
720
+ })()
721
+ )}
722
+ {row(
723
+ 'Ledger retained',
724
+ (() => {
725
+ const days = pick(
726
+ serverBlock,
727
+ 'retain_days',
728
+ 'retainDays'
729
+ );
730
+
731
+ return days === null ? '—' : `${days} day(s)`;
732
+ })()
733
+ )}
734
+ </section>
735
+
736
+ {/* --- round trip ---------------------------------------- */}
737
+ <section className={styles.cqdiagSection}>
738
+ <h3 className={styles.cqdiagHeading}>
739
+ <Send size={14} />
740
+ <span>Round trip</span>
741
+ </h3>
742
+
743
+ <p className={styles.cqdiagCopy}>
744
+ Broadcasts a harmless ping on the call queue channel. It
745
+ pops nothing — it only proves the socket reaches this
746
+ browser.
747
+ </p>
748
+
749
+ <div className={styles.cqdiagActions}>
750
+ <button
751
+ type="button"
752
+ className={styles.cqdiagButton}
753
+ disabled={ping.state === 'sending'}
754
+ onClick={sendPing}
755
+ >
756
+ {ping.state === 'sending' ? (
757
+ <Loader2 size={13} className={styles.spin} />
758
+ ) : (
759
+ <Send size={13} />
760
+ )}
761
+ <span>Send test ping</span>
762
+ </button>
763
+ </div>
764
+
765
+ {pingLine ? (
766
+ <p
767
+ className={`${styles.cqdiagResult} ${tone(pingTone)}`}
768
+ >
769
+ {pingLine}
770
+ </p>
771
+ ) : null}
772
+ </section>
773
+ </div>
774
+
775
+ {/* --- recent webhooks --------------------------------------- */}
776
+ <section className={styles.cqdiagSection}>
777
+ <div className={styles.cqdiagBar}>
778
+ <h3 className={styles.cqdiagHeading}>
779
+ <Activity size={14} />
780
+ <span>Recent webhooks</span>
781
+ </h3>
782
+
783
+ <div className={styles.cqdiagBarActions}>
784
+ <label className={styles.cqdiagCheck}>
785
+ <input
786
+ type="checkbox"
787
+ checked={autoRefresh}
788
+ onChange={(e) =>
789
+ setAutoRefresh(e.target.checked)
790
+ }
791
+ />
792
+ <span>Auto-refresh every 10s</span>
793
+ </label>
794
+
795
+ <button
796
+ type="button"
797
+ className={styles.cqdiagButton}
798
+ disabled={isLoading}
799
+ onClick={load}
800
+ >
801
+ {isLoading ? (
802
+ <Loader2 size={13} className={styles.spin} />
803
+ ) : (
804
+ <RefreshCw size={13} />
805
+ )}
806
+ <span>Refresh</span>
807
+ </button>
808
+ </div>
809
+ </div>
810
+
811
+ <div className={styles.cqdiagSummary}>
812
+ <div className={styles.cqdiagSummaryRow}>
813
+ <span className={styles.cqdiagLabel}>Last 24h</span>
814
+ <span className={styles.cqdiagChips}>
815
+ {countChips(last24h)}
816
+ </span>
817
+ </div>
818
+ <div className={styles.cqdiagSummaryRow}>
819
+ <span className={styles.cqdiagLabel}>Last 7 days</span>
820
+ <span className={styles.cqdiagChips}>
821
+ {countChips(last7d)}
822
+ </span>
823
+ </div>
824
+ <div className={styles.cqdiagSummaryRow}>
825
+ <span className={styles.cqdiagLabel}>
826
+ Broadcast failures (24h)
827
+ </span>
828
+ <span
829
+ className={`${styles.cqdiagValue} ${
830
+ broadcastFailures > 0 ? tone('bad') : ''
831
+ }`}
832
+ >
833
+ {broadcastFailures}
834
+ </span>
835
+ </div>
836
+ <div className={styles.cqdiagSummaryRow}>
837
+ <span className={styles.cqdiagLabel}>
838
+ Last ringing recorded
839
+ </span>
840
+ <span className={styles.cqdiagValue}>
841
+ {formatLedgerTime(
842
+ pick(
843
+ summary,
844
+ 'last_ringing_recorded_at',
845
+ 'lastRingingRecordedAt'
846
+ )
847
+ )}
848
+ </span>
849
+ </div>
850
+ <div className={styles.cqdiagSummaryRow}>
851
+ <span className={styles.cqdiagLabel}>
852
+ Last webhook of any kind
853
+ </span>
854
+ <span className={styles.cqdiagValue}>
855
+ {formatLedgerTime(
856
+ pick(summary, 'last_event_at', 'lastEventAt')
857
+ )}
858
+ </span>
859
+ </div>
860
+ </div>
861
+
862
+ <div className={styles.cqdiagPanel}>
863
+ {events.length === 0 ? (
864
+ <div className={styles.cqdiagState}>
865
+ <span>
866
+ No webhooks recorded yet — the ledger starts
867
+ with the next Zoom event.
868
+ </span>
869
+ </div>
870
+ ) : (
871
+ <table className={styles.cqdiagTable}>
872
+ <thead>
873
+ <tr>
874
+ <th>Time</th>
875
+ <th>Event</th>
876
+ <th>Outcome</th>
877
+ <th>Queue</th>
878
+ <th>Caller</th>
879
+ <th>Call</th>
880
+ <th>Broadcast</th>
881
+ <th>Took</th>
882
+ <th>Error</th>
883
+ </tr>
884
+ </thead>
885
+ <tbody>
886
+ {events.map((event, index) => {
887
+ const at = pick(
888
+ event,
889
+ 'received_at',
890
+ 'receivedAt',
891
+ 'at',
892
+ 'created_at',
893
+ 'createdAt'
894
+ );
895
+ const broadcast = broadcastState(
896
+ pick(
897
+ event,
898
+ 'broadcast',
899
+ 'broadcasted',
900
+ 'broadcast_ok'
901
+ )
902
+ );
903
+ const broadcastMs = pick(
904
+ event,
905
+ 'broadcast_ms',
906
+ 'broadcastMs'
907
+ );
908
+ const name = pick(event, 'event', 'name');
909
+ const outcome =
910
+ pick(event, 'outcome', 'result') ?? '';
911
+ const failedBroadcast =
912
+ broadcast === 'failed';
913
+ // A row the middleware turned away is
914
+ // logged as event 'rejected' with the
915
+ // reason in `outcome`, so the event name
916
+ // has to count towards the verdict too.
917
+ const rowTone = outcomeTone(
918
+ String(name) === 'rejected'
919
+ ? 'rejected'
920
+ : outcome,
921
+ failedBroadcast
922
+ );
923
+ const error = pick(
924
+ event,
925
+ 'error',
926
+ 'error_message'
927
+ );
928
+ const callId = pick(
929
+ event,
930
+ 'call_id',
931
+ 'callId'
932
+ );
933
+
934
+ return (
935
+ <tr
936
+ key={
937
+ pick(event, 'id') ??
938
+ `${callId ?? 'row'}-${index}`
939
+ }
940
+ >
941
+ <td title={at ?? ''}>
942
+ {formatLedgerTime(at)}
943
+ </td>
944
+ <td title={String(name ?? '')}>
945
+ {shortEventName(name)}
946
+ </td>
947
+ <td>
948
+ <span
949
+ className={`${styles.cqdiagBadge} ${tone(
950
+ rowTone
951
+ )}`}
952
+ title={String(outcome)}
953
+ >
954
+ {outcome || '—'}
955
+ </span>
956
+ </td>
957
+ <td
958
+ title={
959
+ pick(
960
+ event,
961
+ 'queue_name',
962
+ 'queueName'
963
+ ) ?? ''
964
+ }
965
+ >
966
+ {truncate(
967
+ pick(
968
+ event,
969
+ 'queue_name',
970
+ 'queueName'
971
+ ) ?? '—',
972
+ 24
973
+ )}
974
+ </td>
975
+ <td>
976
+ {pick(
977
+ event,
978
+ 'caller_number',
979
+ 'callerNumber',
980
+ 'from'
981
+ ) ?? '—'}
982
+ </td>
983
+ <td title={String(callId ?? '')}>
984
+ {shortCallId(callId)}
985
+ </td>
986
+ <td
987
+ className={
988
+ failedBroadcast
989
+ ? tone('bad')
990
+ : undefined
991
+ }
992
+ >
993
+ {broadcast === null
994
+ ? '—'
995
+ : broadcast === 'ok'
996
+ ? `sent${
997
+ broadcastMs
998
+ ? ` · ${broadcastMs} ms`
999
+ : ''
1000
+ }`
1001
+ : 'failed'}
1002
+ </td>
1003
+ <td>
1004
+ {(() => {
1005
+ const took = pick(
1006
+ event,
1007
+ 'duration_ms',
1008
+ 'durationMs'
1009
+ );
1010
+
1011
+ return took === null
1012
+ ? '—'
1013
+ : `${took} ms`;
1014
+ })()}
1015
+ </td>
1016
+ <td title={String(error ?? '')}>
1017
+ {error
1018
+ ? truncate(error, 40)
1019
+ : '—'}
1020
+ </td>
1021
+ </tr>
1022
+ );
1023
+ })}
1024
+ </tbody>
1025
+ </table>
1026
+ )}
1027
+ </div>
1028
+
1029
+ <p className={styles.cqdiagNote}>
1030
+ <strong>How to read this.</strong> A green ledger row for a
1031
+ call the browser never showed is a browser/socket problem —
1032
+ check the socket, the channel and the subscription above. No
1033
+ ledger rows at all during a real call is a Zoom, webhook or
1034
+ throttle problem — the event never reached us. A red
1035
+ “broadcast failed” is the websocket server (Reverb) being
1036
+ down, so nobody was told.
1037
+ </p>
1038
+ </section>
1039
+ </div>
1040
+ );
1041
+ };
1042
+
1043
+ export default CallQueueDiagnostics;