@visns-studio/visns-components 6.24.4 → 6.26.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/package.json +4 -2
- package/src/components/Autocomplete.jsx +189 -119
- package/src/components/DataGrid.jsx +472 -31
- package/src/components/Navigation.jsx +475 -51
- package/src/components/auth/ClientAuthFrame.jsx +5 -0
- package/src/components/auth/ClientAuthScreen.jsx +29 -0
- package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
- package/src/components/callQueue/CallQueuePop.jsx +713 -90
- package/src/components/callQueue/CallQueueSettings.jsx +308 -146
- package/src/components/callQueue/callPopStatus.js +236 -0
- package/src/components/callQueue/callQueueHelpers.js +284 -2
- package/src/components/columns/ColumnRenderers.jsx +3 -46
- package/src/components/columns/StackedRow.jsx +186 -0
- package/src/components/controls/DataGridSearch.jsx +110 -2
- package/src/components/controls/DataGridSortSheet.jsx +155 -0
- package/src/components/generic/GenericAuth.jsx +50 -18
- package/src/components/generic/GenericDashboard.jsx +20 -1
- package/src/components/generic/GenericDetail.jsx +446 -259
- package/src/components/mapboxSearchBox.js +640 -0
- package/src/components/navBadges.js +63 -1
- package/src/components/navDrawer.js +147 -0
- package/src/components/sms/SmsThreadPanel.jsx +34 -6
- package/src/components/sms/smsHelpers.js +15 -0
- package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
- package/src/components/styles/CallQueuePop.module.scss +29 -0
- package/src/components/styles/CallQueueSettings.module.scss +93 -0
- package/src/components/styles/ClientAuth.module.scss +39 -0
- package/src/components/styles/DataGrid.module.scss +158 -5
- package/src/components/styles/Field.module.scss +52 -1
- package/src/components/styles/Form.module.scss +82 -0
- package/src/components/styles/GenericClientPortal.module.scss +72 -20
- package/src/components/styles/GenericDashboard.module.scss +50 -0
- package/src/components/styles/GenericDetail.module.scss +63 -1
- package/src/components/styles/GenericDynamic.module.scss +23 -0
- package/src/components/styles/GenericFormBuilder.module.scss +11 -0
- package/src/components/styles/GenericIndex.module.scss +6 -1
- package/src/components/styles/Navigation.module.scss +460 -7
- package/src/components/styles/Sms.module.scss +92 -0
- package/src/components/styles/StackedRow.module.scss +182 -0
- package/src/components/styles/TicketConversation.module.scss +76 -0
- package/src/components/styles/Vault.module.scss +192 -0
- package/src/components/styles/density.css +10 -0
- package/src/components/styles/global-datagrid.css +163 -0
- package/src/components/styles/global.css +20 -0
- package/src/components/tickets/TicketConversation.jsx +13 -8
- package/src/components/utils/ConfirmDialog.js +22 -3
- package/src/components/utils/cardLayout.js +666 -0
- package/src/components/utils/contactChannels.js +130 -0
- package/src/components/utils/editPlacement.js +95 -0
- package/src/components/utils/useDensity.js +303 -7
- package/src/index.js +42 -0
|
@@ -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;
|