@uniai-fe/uds-templates 0.6.14 → 0.6.16

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/dist/styles.css CHANGED
@@ -1796,7 +1796,7 @@
1796
1796
  flex-direction: column;
1797
1797
  align-items: center;
1798
1798
  justify-content: center;
1799
- gap: 2px;
1799
+ gap: var(--spacing-gap-4);
1800
1800
  }
1801
1801
 
1802
1802
  .cctv-video-loading-icon {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,12 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
4
- import {
5
- getCctvDebugJwtMetadata,
6
- getCctvDebugKeyLabel,
7
- getCctvDebugUrlLabel,
8
- logCctvDebugEvent,
9
- } from "../utils/debug";
10
4
 
11
5
  interface CctvRtcStreamSnapshot {
12
6
  connectionState: RTCPeerConnectionState;
@@ -32,8 +26,6 @@ interface CctvRtcStreamStartParams {
32
26
  video: HTMLVideoElement;
33
27
  }
34
28
 
35
- const WHEP_ERROR_BODY_PREVIEW_LIMIT = 1000;
36
-
37
29
  const getEntrySnapshot = (
38
30
  entry?: CctvRtcStreamEntry,
39
31
  ): CctvRtcStreamSnapshot => ({
@@ -63,69 +55,7 @@ const notifyEntry = (entry: CctvRtcStreamEntry) => {
63
55
  entry.listeners.forEach(listener => listener(snapshot));
64
56
  };
65
57
 
66
- const getWhepResponseBodyPreview = async (
67
- response: Response,
68
- ): Promise<string | null> => {
69
- try {
70
- const text = await response.clone().text();
71
- return text.slice(0, WHEP_ERROR_BODY_PREVIEW_LIMIT);
72
- } catch {
73
- return null;
74
- }
75
- };
76
-
77
- /**
78
- * WHEP 실패 body를 startWhepStream 외부에서 관찰하기 위한 fetch wrapper.
79
- * @desc
80
- * 성공 응답은 그대로 통과시키고, 실패 응답에서만 clone body를 읽어 debug log에 남긴다.
81
- */
82
- const createCctvDebugWhepFetcher =
83
- ({
84
- endpoint,
85
- identityKey,
86
- streamKey,
87
- token,
88
- }: {
89
- endpoint: string;
90
- identityKey: string;
91
- streamKey: string;
92
- token: string;
93
- }): typeof fetch =>
94
- async (input, init) => {
95
- const response = await fetch(input, init);
96
- if (response.ok) return response;
97
-
98
- logCctvDebugEvent({
99
- event: "whep:response-error",
100
- level: "error",
101
- payload: {
102
- endpoint: getCctvDebugUrlLabel(endpoint),
103
- identityKey: getCctvDebugKeyLabel(identityKey),
104
- responseBody: await getWhepResponseBodyPreview(response),
105
- status: response.status,
106
- statusText: response.statusText,
107
- streamKey: getCctvDebugKeyLabel(streamKey),
108
- token: getCctvDebugJwtMetadata(token),
109
- },
110
- source: "streamRegistry",
111
- });
112
-
113
- return response;
114
- };
115
-
116
58
  const closeEntry = (entry: CctvRtcStreamEntry) => {
117
- logCctvDebugEvent({
118
- event: "entry:close",
119
- payload: {
120
- attachedVideoCount: entry.attachedVideos.size,
121
- connectionState: entry.connectionState,
122
- identityKey: getCctvDebugKeyLabel(entry.identityKey),
123
- isStreaming: entry.isStreaming,
124
- streamKey: getCctvDebugKeyLabel(entry.streamKey),
125
- streamTrackCount: entry.stream?.getTracks().length ?? 0,
126
- },
127
- source: "streamRegistry",
128
- });
129
59
  entry.controller?.abort();
130
60
  entry.controller = null;
131
61
  entry.handle?.close();
@@ -187,15 +117,6 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
187
117
  entries.clear();
188
118
  },
189
119
  closeByIdentity(identityKey, exceptStreamKey) {
190
- logCctvDebugEvent({
191
- event: "identity:close",
192
- payload: {
193
- entryCount: entries.size,
194
- exceptStreamKey: getCctvDebugKeyLabel(exceptStreamKey),
195
- identityKey: getCctvDebugKeyLabel(identityKey),
196
- },
197
- source: "streamRegistry",
198
- });
199
120
  entries.forEach(entry => {
200
121
  if (
201
122
  entry.identityKey === identityKey &&
@@ -211,21 +132,10 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
211
132
  start({ endpoint, identityKey, streamKey, token, video }) {
212
133
  const existingEntry = entries.get(streamKey);
213
134
  if (existingEntry) {
214
- logCctvDebugEvent({
215
- event: "start:reuse-existing",
216
- payload: {
217
- connectionState: existingEntry.connectionState,
218
- identityKey: getCctvDebugKeyLabel(identityKey),
219
- isStreaming: existingEntry.isStreaming,
220
- streamKey: getCctvDebugKeyLabel(streamKey),
221
- },
222
- source: "streamRegistry",
223
- });
224
135
  return;
225
136
  }
226
137
 
227
138
  const controller = new AbortController();
228
- const startedAt = Date.now();
229
139
  const entry: CctvRtcStreamEntry = {
230
140
  attachedVideos: new Set([video]),
231
141
  connectionState: "connecting",
@@ -246,58 +156,18 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
246
156
  video.playsInline = true;
247
157
  video.autoplay = true;
248
158
  }
249
- logCctvDebugEvent({
250
- event: "start:create-entry",
251
- payload: {
252
- attachedVideoCount: entry.attachedVideos.size,
253
- endpoint: getCctvDebugUrlLabel(endpoint),
254
- identityKey: getCctvDebugKeyLabel(identityKey),
255
- streamKey: getCctvDebugKeyLabel(streamKey),
256
- token: getCctvDebugJwtMetadata(token),
257
- },
258
- source: "streamRegistry",
259
- });
260
159
 
261
160
  startWhepStream({
262
161
  endpoint,
263
- fetcher: createCctvDebugWhepFetcher({
264
- endpoint,
265
- identityKey,
266
- streamKey,
267
- token,
268
- }),
269
162
  token,
270
163
  video,
271
164
  signal: controller.signal,
272
165
  onConnectionStateChange: state => {
273
166
  entry.connectionState = state;
274
- logCctvDebugEvent({
275
- event: "connection-state:change",
276
- payload: {
277
- elapsedMs: Date.now() - startedAt,
278
- identityKey: getCctvDebugKeyLabel(identityKey),
279
- state,
280
- streamKey: getCctvDebugKeyLabel(streamKey),
281
- },
282
- source: "streamRegistry",
283
- });
284
167
  notifyEntry(entry);
285
168
  },
286
169
  onTrack: event => {
287
170
  entry.stream = event.streams[0] ?? null;
288
- logCctvDebugEvent({
289
- event: "track:received",
290
- payload: {
291
- elapsedMs: Date.now() - startedAt,
292
- identityKey: getCctvDebugKeyLabel(identityKey),
293
- streamId: entry.stream?.id ?? null,
294
- streamKey: getCctvDebugKeyLabel(streamKey),
295
- trackId: event.track.id,
296
- trackKind: event.track.kind,
297
- trackReadyState: event.track.readyState,
298
- },
299
- source: "streamRegistry",
300
- });
301
171
  entry.attachedVideos.forEach(attachedVideo =>
302
172
  attachVideo(attachedVideo, entry.stream),
303
173
  );
@@ -307,16 +177,6 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
307
177
  .then(handle => {
308
178
  entry.handle = handle;
309
179
  entry.isStreaming = false;
310
- logCctvDebugEvent({
311
- event: "start:resolved",
312
- level: "info",
313
- payload: {
314
- elapsedMs: Date.now() - startedAt,
315
- identityKey: getCctvDebugKeyLabel(identityKey),
316
- streamKey: getCctvDebugKeyLabel(streamKey),
317
- },
318
- source: "streamRegistry",
319
- });
320
180
  notifyEntry(entry);
321
181
  })
322
182
  .catch(error => {
@@ -328,17 +188,6 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
328
188
  error instanceof Error
329
189
  ? error.message
330
190
  : "스트림 연결에 실패했습니다.";
331
- logCctvDebugEvent({
332
- event: "start:rejected",
333
- level: "error",
334
- payload: {
335
- elapsedMs: Date.now() - startedAt,
336
- errorMessage: entry.streamError,
337
- identityKey: getCctvDebugKeyLabel(identityKey),
338
- streamKey: getCctvDebugKeyLabel(streamKey),
339
- },
340
- source: "streamRegistry",
341
- });
342
191
  entry.attachedVideos.forEach(attachedVideo =>
343
192
  clearVideoIfAttachedStream(attachedVideo, entry.stream),
344
193
  );
@@ -19,11 +19,6 @@ import type {
19
19
  UseCctvRtcStreamReturn,
20
20
  } from "../types";
21
21
  import { cctvRtcLiveRegistryAtom } from "../jotai/rtc";
22
- import {
23
- getCctvDebugKeyLabel,
24
- getCctvDebugStackTrace,
25
- logCctvDebugEvent,
26
- } from "../utils/debug";
27
22
  import { getIsLive } from "../utils/video-state";
28
23
  import { useFormContext, useWatch } from "react-hook-form";
29
24
 
@@ -138,7 +133,7 @@ const canUseTokenForNewConnection = ({
138
133
  * @desc
139
134
  * return {
140
135
  * videoRef, // WebRTC MediaStream을 연결할 video 요소 ref
141
- * connectionState, // RTCPeerConnectionState 상태값
136
+ * connectionState, // UI 표시 기준 RTCPeerConnectionState 상태값
142
137
  * streamError, // 스트림 시도 중 발생한 오류 메시지
143
138
  * isStreaming, // UI 표시 기준 스트림 연결 절차 진행 여부
144
139
  * isTokenLoading, // UI 표시 기준 토큰 발급 요청 진행 여부
@@ -236,23 +231,9 @@ export function useCctvRtcStream({
236
231
  return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
237
232
  }, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
238
233
 
239
- const debugBasePayload = useMemo(
240
- () => ({
241
- camId: cam?.cam_id,
242
- companyId: cam?.company_id,
243
- streamIdentityKey: getCctvDebugKeyLabel(streamIdentityKey),
244
- }),
245
- [cam?.cam_id, cam?.company_id, streamIdentityKey],
246
- );
247
-
248
234
  useEffect(() => {
249
- logCctvDebugEvent({
250
- event: "identity:reset-has-connected",
251
- payload: debugBasePayload,
252
- source: "useRtcStream",
253
- });
254
235
  setHasConnected(false);
255
- }, [debugBasePayload, streamIdentityKey]);
236
+ }, [streamIdentityKey]);
256
237
 
257
238
  const reconnectReason = useMemo(
258
239
  () =>
@@ -282,45 +263,14 @@ export function useCctvRtcStream({
282
263
  `${streamIdentityKey}|${reconnectReason}`,
283
264
  );
284
265
 
285
- logCctvDebugEvent({
286
- event: "reconnect-gate:timer-start",
287
- payload: {
288
- ...debugBasePayload,
289
- isStreaming,
290
- isTokenLoading,
291
- reconnectDelayMs,
292
- reconnectReason,
293
- },
294
- source: "useRtcStream",
295
- });
296
-
297
266
  const timeout = setTimeout(() => {
298
267
  setPostConnectedReconnectReady(true);
299
- logCctvDebugEvent({
300
- event: "reconnect-gate:ready",
301
- payload: {
302
- ...debugBasePayload,
303
- reconnectDelayMs,
304
- reconnectReason,
305
- },
306
- source: "useRtcStream",
307
- });
308
268
  }, reconnectDelayMs);
309
269
 
310
270
  return () => {
311
271
  clearTimeout(timeout);
312
- logCctvDebugEvent({
313
- event: "reconnect-gate:timer-cancel",
314
- payload: {
315
- ...debugBasePayload,
316
- reconnectDelayMs,
317
- reconnectReason,
318
- },
319
- source: "useRtcStream",
320
- });
321
272
  };
322
273
  }, [
323
- debugBasePayload,
324
274
  hasConnected,
325
275
  isStreaming,
326
276
  isTokenLoading,
@@ -328,6 +278,13 @@ export function useCctvRtcStream({
328
278
  streamIdentityKey,
329
279
  ]);
330
280
 
281
+ const isPostConnectedRecoverableState =
282
+ hasConnected &&
283
+ Boolean(streamIdentityKey) &&
284
+ !isTokenError &&
285
+ !streamError &&
286
+ DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState);
287
+
331
288
  const isPostConnectedReplacementLoading =
332
289
  hasConnected &&
333
290
  Boolean(streamIdentityKey) &&
@@ -336,13 +293,10 @@ export function useCctvRtcStream({
336
293
  (isTokenLoading || isStreaming);
337
294
 
338
295
  const shouldPreserveConnectedDisplay =
339
- isPostConnectedReplacementLoading ||
340
- (hasConnected &&
341
- !isPostConnectedReconnectReady &&
342
- DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState));
296
+ isPostConnectedReplacementLoading || isPostConnectedRecoverableState;
343
297
 
344
298
  // 반환 state는 CamList/Viewer/overlay의 live/error/message 계산에 직접 쓰인다.
345
- // post-connected recovery/replacement 중에는 기존 화면을 유지해 UI reset 파동을 막는다.
299
+ // post-connected recovery/replacement 중에는 canReconnect만 열고, 기존 화면은 유지해 UI reset 파동을 막는다.
346
300
  const displayConnectionState = shouldPreserveConnectedDisplay
347
301
  ? "connected"
348
302
  : connectionState;
@@ -353,36 +307,6 @@ export function useCctvRtcStream({
353
307
  ? false
354
308
  : isTokenLoading;
355
309
 
356
- const skippedDisconnectedDebugKeyRef = useRef<string | null>(null);
357
-
358
- useEffect(() => {
359
- if (
360
- connectionState !== "disconnected" ||
361
- !hasConnected ||
362
- !streamIdentityKey
363
- ) {
364
- if (connectionState !== "disconnected") {
365
- skippedDisconnectedDebugKeyRef.current = null;
366
- }
367
- return;
368
- }
369
-
370
- const skipKey = `${streamIdentityKey}|${activeStreamKeyRef.current ?? ""}`;
371
- if (skippedDisconnectedDebugKeyRef.current === skipKey) return;
372
-
373
- skippedDisconnectedDebugKeyRef.current = skipKey;
374
- logCctvDebugEvent({
375
- event: "reconnect-gate:skip-disconnected",
376
- payload: {
377
- ...debugBasePayload,
378
- connectionState,
379
- reason: "disconnected-observed-only",
380
- streamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
381
- },
382
- source: "useRtcStream",
383
- });
384
- }, [connectionState, debugBasePayload, hasConnected, streamIdentityKey]);
385
-
386
310
  const hasReusableRegistryStream = useMemo(() => {
387
311
  if (!streamKeyCandidate) return false;
388
312
 
@@ -432,19 +356,9 @@ export function useCctvRtcStream({
432
356
  }
433
357
 
434
358
  staleTokenRefreshKeyRef.current = streamKeyCandidate;
435
- logCctvDebugEvent({
436
- event: "token:stale-refetch",
437
- payload: {
438
- ...debugBasePayload,
439
- dataUpdatedAt: tokenQuery.dataUpdatedAt,
440
- streamKey: getCctvDebugKeyLabel(streamKeyCandidate),
441
- },
442
- source: "useRtcStream",
443
- });
444
359
  void refetchRtcToken();
445
360
  }, [
446
361
  canUseTokenForStream,
447
- debugBasePayload,
448
362
  refetchRtcToken,
449
363
  streamKeyCandidate,
450
364
  tokenQuery.data?.token,
@@ -459,15 +373,6 @@ export function useCctvRtcStream({
459
373
 
460
374
  // 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
461
375
  if (tokenQuery.isError || !cam?.cam_online) {
462
- logCctvDebugEvent({
463
- event: "stream-effect:cleanup-token-or-offline",
464
- payload: {
465
- ...debugBasePayload,
466
- camOnline: cam?.cam_online,
467
- isTokenError: tokenQuery.isError,
468
- },
469
- source: "useRtcStream",
470
- });
471
376
  if (streamIdentityKey) {
472
377
  streamRegistry.closeByIdentity(streamIdentityKey);
473
378
  }
@@ -493,42 +398,12 @@ export function useCctvRtcStream({
493
398
  if (!previousStreamKey || didClosePreviousStream) return;
494
399
 
495
400
  didClosePreviousStream = true;
496
- logCctvDebugEvent({
497
- event: "stream-effect:close-previous-after-track",
498
- payload: {
499
- ...debugBasePayload,
500
- previousStreamKey: getCctvDebugKeyLabel(previousStreamKey),
501
- streamKey: getCctvDebugKeyLabel(streamKey),
502
- },
503
- source: "useRtcStream",
504
- });
505
401
  streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
506
402
  };
507
403
 
508
- if (previousStreamKey) {
509
- logCctvDebugEvent({
510
- event: "stream-effect:prepare-replacement",
511
- payload: {
512
- ...debugBasePayload,
513
- previousStreamKey: getCctvDebugKeyLabel(previousStreamKey),
514
- streamKey: getCctvDebugKeyLabel(streamKey),
515
- },
516
- source: "useRtcStream",
517
- });
518
- }
519
-
520
404
  activeStreamKeyRef.current = streamKey;
521
405
  activeStreamIdentityKeyRef.current = streamIdentityKey;
522
406
 
523
- logCctvDebugEvent({
524
- event: "stream-effect:start",
525
- payload: {
526
- ...debugBasePayload,
527
- streamKey: getCctvDebugKeyLabel(streamKey),
528
- },
529
- source: "useRtcStream",
530
- });
531
-
532
407
  streamRegistry.start({
533
408
  streamKey,
534
409
  identityKey: streamIdentityKey,
@@ -543,14 +418,6 @@ export function useCctvRtcStream({
543
418
  setStreamError(snapshot.streamError);
544
419
  setStreaming(snapshot.isStreaming);
545
420
  if (snapshot.connectionState === "connected") {
546
- logCctvDebugEvent({
547
- event: "stream-effect:has-connected",
548
- payload: {
549
- ...debugBasePayload,
550
- streamKey: getCctvDebugKeyLabel(streamKey),
551
- },
552
- source: "useRtcStream",
553
- });
554
421
  setHasConnected(true);
555
422
  }
556
423
  if (snapshot.stream) {
@@ -575,19 +442,10 @@ export function useCctvRtcStream({
575
442
 
576
443
  // effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
577
444
  return () => {
578
- logCctvDebugEvent({
579
- event: "stream-effect:detach-video",
580
- payload: {
581
- ...debugBasePayload,
582
- streamKey: getCctvDebugKeyLabel(streamKey),
583
- },
584
- source: "useRtcStream",
585
- });
586
445
  unsubscribe();
587
446
  detachVideo();
588
447
  };
589
448
  }, [
590
- debugBasePayload,
591
449
  endpoint,
592
450
  streamIdentityKey,
593
451
  streamKey,
@@ -597,52 +455,8 @@ export function useCctvRtcStream({
597
455
  cam?.cam_online,
598
456
  ]);
599
457
 
600
- const debugReconnectStateRef = useRef({
601
- connectionState,
602
- debugBasePayload,
603
- isPostConnectedReconnectReady,
604
- reconnectReason,
605
- streamError,
606
- });
607
- debugReconnectStateRef.current = {
608
- connectionState,
609
- debugBasePayload,
610
- isPostConnectedReconnectReady,
611
- reconnectReason,
612
- streamError,
613
- };
614
-
615
458
  const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
616
- async options => {
617
- const debugState = debugReconnectStateRef.current;
618
- logCctvDebugEvent({
619
- event: "reconnect-stream:start",
620
- payload: {
621
- ...debugState.debugBasePayload,
622
- connectionState: debugState.connectionState,
623
- callerStack: getCctvDebugStackTrace(),
624
- isPostConnectedReconnectReady:
625
- debugState.isPostConnectedReconnectReady,
626
- reconnectReason: debugState.reconnectReason,
627
- streamError: debugState.streamError,
628
- },
629
- source: "useRtcStream",
630
- });
631
- const result = await refetchRtcToken(options);
632
- const resultDebugState = debugReconnectStateRef.current;
633
- logCctvDebugEvent({
634
- event: "reconnect-stream:token-result",
635
- level: result.isSuccess ? "info" : "warn",
636
- payload: {
637
- ...resultDebugState.debugBasePayload,
638
- isError: result.isError,
639
- isSuccess: result.isSuccess,
640
- status: result.status,
641
- },
642
- source: "useRtcStream",
643
- });
644
- return result;
645
- },
459
+ options => refetchRtcToken(options),
646
460
  [refetchRtcToken],
647
461
  );
648
462
 
@@ -678,16 +492,6 @@ export function useCctvRtcStream({
678
492
  return;
679
493
 
680
494
  lastAutoReconnectAtRef.current = now;
681
- logCctvDebugEvent({
682
- event: "focus:auto-reconnect",
683
- payload: {
684
- ...debugBasePayload,
685
- canReconnect,
686
- connectionState,
687
- reconnectReason,
688
- },
689
- source: "useRtcStream",
690
- });
691
495
  void reconnectStream();
692
496
  };
693
497
 
@@ -698,13 +502,7 @@ export function useCctvRtcStream({
698
502
  window.removeEventListener("focus", reconnectOnFocus);
699
503
  document.removeEventListener("visibilitychange", reconnectOnFocus);
700
504
  };
701
- }, [
702
- canReconnect,
703
- connectionState,
704
- debugBasePayload,
705
- reconnectReason,
706
- reconnectStream,
707
- ]);
505
+ }, [canReconnect, reconnectStream]);
708
506
 
709
507
  const liveState = useMemo(
710
508
  () =>
@@ -728,46 +526,6 @@ export function useCctvRtcStream({
728
526
  ],
729
527
  );
730
528
 
731
- useEffect(() => {
732
- logCctvDebugEvent({
733
- event: "hook-state:update",
734
- payload: {
735
- ...debugBasePayload,
736
- canReconnect,
737
- connectionState,
738
- displayConnectionState,
739
- hasConnected,
740
- isPostConnectedReplacementLoading,
741
- isPostConnectedReconnectReady,
742
- displayIsStreaming,
743
- displayIsTokenLoading,
744
- isStreaming,
745
- isTokenError,
746
- isTokenLoading,
747
- liveState,
748
- reconnectReason,
749
- streamError,
750
- },
751
- source: "useRtcStream",
752
- });
753
- }, [
754
- canReconnect,
755
- connectionState,
756
- debugBasePayload,
757
- displayConnectionState,
758
- displayIsStreaming,
759
- displayIsTokenLoading,
760
- hasConnected,
761
- isPostConnectedReplacementLoading,
762
- isPostConnectedReconnectReady,
763
- isStreaming,
764
- isTokenError,
765
- isTokenLoading,
766
- liveState,
767
- reconnectReason,
768
- streamError,
769
- ]);
770
-
771
529
  useEffect(() => {
772
530
  const camId = cam?.cam_id;
773
531
  if (!camId) return;
@@ -137,7 +137,7 @@
137
137
  flex-direction: column;
138
138
  align-items: center;
139
139
  justify-content: center;
140
- gap: 2px;
140
+ gap: var(--spacing-gap-4);
141
141
  }
142
142
  .cctv-video-loading-icon {
143
143
  flex: 0 0 auto;
@@ -59,7 +59,7 @@ export interface UseCctvRtcStreamError {
59
59
 
60
60
  /**
61
61
  * CCTV; useCctvRtcStream 연결 상태
62
- * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
62
+ * @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
63
63
  * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
64
64
  * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
65
65
  * @property {boolean} isTokenError 토큰 발급 실패 여부
@@ -67,7 +67,11 @@ export interface UseCctvRtcStreamError {
67
67
  */
68
68
  export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
69
69
  /**
70
- * WebRTC 연결 상태
70
+ * UI 표시 기준 WebRTC 연결 상태
71
+ * @desc
72
+ * 최초 연결 전에는 실제 PeerConnection 상태를 나타낸다. 이미 한 번 연결된 stream이
73
+ * recoverable `disconnected`/`failed` 상태에 들어가면 기존 화면과 live count 유지를 위해
74
+ * `connected`로 smoothing될 수 있다.
71
75
  */
72
76
  connectionState: RTCPeerConnectionState;
73
77
  /**
@@ -89,7 +93,7 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
89
93
  /**
90
94
  * CCTV; useCctvRtcStream return
91
95
  * @property {React.RefObject<HTMLVideoElement>} videoRef <video /> ref
92
- * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
96
+ * @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
93
97
  * @property {string | null} streamError 스트림 오류 메시지
94
98
  * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
95
99
  * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
@@ -110,6 +114,7 @@ export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
110
114
  * 서비스 overlay가 reconnectStream()을 호출해도 되는 시점에 true가 된다.
111
115
  * post-connected `disconnected`는 자동 재연결 사유가 아니며,
112
116
  * token/stream 오류 또는 `failed`처럼 명확한 복구 사유만 true 전환 후보가 된다.
117
+ * recoverable `failed`에서는 화면이 live로 유지되는 동안에도 true가 될 수 있다.
113
118
  */
114
119
  canReconnect: boolean;
115
120
  /**
@@ -6,12 +6,13 @@ import type { UseCctvRtcStreamError, UseCctvRtcStreamState } from "./hook";
6
6
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
7
7
  * @property {boolean} [hasCamProp] 직접 cam prop을 받은 경우 true
8
8
  * @property {boolean} [isFetching] 서버에서 cam 리스트를 가져오는 중인지 여부
9
- * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
9
+ * @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
10
10
  * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
11
11
  * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
12
12
  * @property {boolean} isTokenError 토큰 발급 실패 여부
13
13
  * @property {string | null} streamError 스트림 오류 메시지
14
- * @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
14
+ * @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부.
15
+ * 오류 스타일 판정에는 직접 사용하지 않는다.
15
16
  */
16
17
  export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
17
18
  /**
@@ -28,6 +29,8 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
28
29
  isFetching?: boolean;
29
30
  /**
30
31
  * UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
32
+ * @desc
33
+ * 재연결 trigger gate이며, 기본 error style 판정에는 직접 사용하지 않는다.
31
34
  */
32
35
  canReconnect?: boolean;
33
36
  }
@@ -35,7 +38,7 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
35
38
  /**
36
39
  * CCTV; getIsLive() params
37
40
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
38
- * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
41
+ * @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
39
42
  * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
40
43
  * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
41
44
  * @property {boolean} isTokenError 토큰 발급 실패 여부
@@ -53,7 +56,8 @@ export interface CctvVideoLiveParams extends UseCctvRtcStreamState {
53
56
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
54
57
  * @property {boolean} isTokenError 토큰 발급 실패 여부
55
58
  * @property {string | null} streamError 스트림 오류 메시지
56
- * @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
59
+ * @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부.
60
+ * 오류 스타일 판정에는 직접 사용하지 않는다.
57
61
  */
58
62
  export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
59
63
  /**
@@ -62,6 +66,8 @@ export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
62
66
  cam?: CctvCompanyCameraData;
63
67
  /**
64
68
  * UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
69
+ * @desc
70
+ * 재연결 trigger gate이며, 기본 error style 판정에는 직접 사용하지 않는다.
65
71
  */
66
72
  canReconnect?: boolean;
67
73
  }
@@ -31,7 +31,7 @@ const RTC_SESSION_ENDED_RECONNECT_STATES = new Set<RTCPeerConnectionState>([
31
31
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
32
32
  * @property {boolean} [hasCamProp] 직접 cam prop을 받은 경우 true
33
33
  * @property {boolean} [isFetching] 서버에서 cam 리스트를 가져오는 중인지 여부
34
- * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
34
+ * @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
35
35
  * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
36
36
  * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
37
37
  * @property {boolean} isTokenError 토큰 발급 실패 여부
@@ -72,7 +72,7 @@ export function getOverlayMessage({
72
72
  * CCTV; 스트리밍 라이브 상태 판단
73
73
  * @param {CctvVideoLiveParams} params 상태 파라미터
74
74
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
75
- * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
75
+ * @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
76
76
  * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
77
77
  * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
78
78
  * @property {boolean} isTokenError 토큰 발급 실패 여부
@@ -102,17 +102,16 @@ export function getIsLive({
102
102
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
103
103
  * @property {boolean} isTokenError 토큰 발급 실패 여부
104
104
  * @property {string | null} streamError 스트림 오류 메시지
105
+ * @property {boolean} [canReconnect] 재연결 trigger 가능 여부. 오류 표시 여부에는 직접 사용하지 않는다.
105
106
  * @returns {boolean} 에러 상태 여부 반환
106
107
  */
107
108
  export function getIsError({
108
109
  cam,
109
- canReconnect,
110
110
  isTokenError,
111
111
  streamError,
112
112
  }: CctvVideoErrorParams): boolean {
113
113
  if (!cam) return true;
114
114
  if (!cam.cam_online) return true;
115
- if (canReconnect) return true;
116
115
  if (isTokenError) return true;
117
116
  if (streamError) return true;
118
117
  return false;
@@ -1,461 +0,0 @@
1
- "use client";
2
-
3
- export type CctvDebugEventLevel = "debug" | "info" | "warn" | "error";
4
-
5
- export interface CctvDebugEvent {
6
- at: string;
7
- event: string;
8
- level: CctvDebugEventLevel;
9
- payload?: Record<string, unknown>;
10
- seq: number;
11
- source: string;
12
- }
13
-
14
- export interface CctvDebugReconnectStartSummary {
15
- at: string;
16
- caller: string | null;
17
- camId: string | null;
18
- connectionState: string | null;
19
- isPostConnectedReconnectReady: boolean | null;
20
- reconnectReason: string | null;
21
- }
22
-
23
- export interface CctvDebugSummary {
24
- connectionStateCounts: Record<string, number>;
25
- eventCounts: Record<string, number>;
26
- first: string | null;
27
- generatedAt: string;
28
- last: string | null;
29
- reconnectCallers: Record<string, number>;
30
- reconnectReasons: Record<string, number>;
31
- reconnectStarts: CctvDebugReconnectStartSummary[];
32
- total: number;
33
- }
34
-
35
- export interface CctvDebugJwtMetadata {
36
- expiresAt: string | null;
37
- expiresInSec: number | null;
38
- headerAlg: string | null;
39
- headerKid: string | null;
40
- tokenLabel: string | null;
41
- tokenPartCount: number;
42
- }
43
-
44
- export interface CctvDebugBuffer {
45
- clear: () => void;
46
- dump: () => CctvDebugEvent[];
47
- enabled: true;
48
- events: CctvDebugEvent[];
49
- filter: (keywords?: readonly string[]) => CctvDebugEvent[];
50
- limit: number;
51
- sequence: number;
52
- summary: () => CctvDebugSummary;
53
- }
54
-
55
- declare global {
56
- interface Window {
57
- __UDS_CCTV_DEBUG__?: CctvDebugBuffer;
58
- }
59
- }
60
-
61
- const DEBUG_QUERY_KEYS = ["udsCctvDebug", "cctvDebug"] as const;
62
- const DEBUG_STORAGE_KEYS = ["uds:cctv:debug", "UDS_CCTV_DEBUG"] as const;
63
- const DEBUG_CONSOLE_QUERY_KEYS = [
64
- "udsCctvDebugConsole",
65
- "cctvDebugConsole",
66
- ] as const;
67
- const DEBUG_CONSOLE_STORAGE_KEYS = [
68
- "uds:cctv:debug:console",
69
- "UDS_CCTV_DEBUG_CONSOLE",
70
- ] as const;
71
- const DEBUG_ENABLED_VALUES = new Set(["1", "true", "yes", "on", "debug"]);
72
- const DEBUG_DISABLED_VALUES = new Set(["0", "false", "no", "off"]);
73
- const DEBUG_BUFFER_LIMIT = 1000;
74
- const DEBUG_DEFAULT_FILTER_KEYWORDS = [
75
- "reconnect",
76
- "close",
77
- "rejected",
78
- "connection-state:change",
79
- "track:received",
80
- ] as const;
81
-
82
- const isBrowser = (): boolean =>
83
- typeof window !== "undefined" && typeof document !== "undefined";
84
-
85
- const getDebugValueOverride = (value: string | null): boolean | null => {
86
- if (value === "") return true;
87
- if (typeof value !== "string") return null;
88
-
89
- const normalizedValue = value.toLowerCase();
90
- if (DEBUG_ENABLED_VALUES.has(normalizedValue)) return true;
91
- if (DEBUG_DISABLED_VALUES.has(normalizedValue)) return false;
92
- return null;
93
- };
94
-
95
- const getDebugQueryOverride = (keys: readonly string[]): boolean | null => {
96
- const query = new URLSearchParams(window.location.search);
97
- for (const key of keys) {
98
- if (!query.has(key)) continue;
99
-
100
- const override = getDebugValueOverride(query.get(key));
101
- if (override !== null) return override;
102
- }
103
-
104
- return null;
105
- };
106
-
107
- const getDebugStorageOverride = (keys: readonly string[]): boolean | null => {
108
- for (const key of keys) {
109
- try {
110
- const override = getDebugValueOverride(window.localStorage.getItem(key));
111
- if (override !== null) return override;
112
- } catch {
113
- return false;
114
- }
115
- }
116
-
117
- return null;
118
- };
119
-
120
- const getDebugConsoleOverride = (): boolean | null => {
121
- const queryOverride = getDebugQueryOverride(DEBUG_CONSOLE_QUERY_KEYS);
122
- if (queryOverride !== null) return queryOverride;
123
-
124
- return getDebugStorageOverride(DEBUG_CONSOLE_STORAGE_KEYS);
125
- };
126
-
127
- const isLocalhostDebugDefaultEnabled = (): boolean => {
128
- const { hostname } = window.location;
129
- return (
130
- hostname === "localhost" ||
131
- hostname === "127.0.0.1" ||
132
- hostname === "[::1]" ||
133
- hostname === "::1" ||
134
- hostname.endsWith(".localhost")
135
- );
136
- };
137
-
138
- const getPayloadString = (
139
- payload: Record<string, unknown> | undefined,
140
- key: string,
141
- ): string | null => {
142
- const value = payload?.[key];
143
- return typeof value === "string" ? value : null;
144
- };
145
-
146
- const getPayloadBoolean = (
147
- payload: Record<string, unknown> | undefined,
148
- key: string,
149
- ): boolean | null => {
150
- const value = payload?.[key];
151
- return typeof value === "boolean" ? value : null;
152
- };
153
-
154
- const getReconnectCaller = (
155
- payload: Record<string, unknown> | undefined,
156
- ): string | null => {
157
- const callerStack = payload?.callerStack;
158
- const stackLines = Array.isArray(callerStack)
159
- ? callerStack.filter((line): line is string => typeof line === "string")
160
- : typeof callerStack === "string"
161
- ? callerStack.split("\n")
162
- : [];
163
-
164
- return (
165
- stackLines
166
- .find(
167
- line =>
168
- line.includes("CCTVManagerVideoOverlay") ||
169
- line.includes("reconnectOnFocus"),
170
- )
171
- ?.trim() ?? null
172
- );
173
- };
174
-
175
- const countBy = <T extends string>(
176
- target: Record<T, number>,
177
- key: T | null,
178
- ): void => {
179
- if (!key) return;
180
- target[key] = (target[key] ?? 0) + 1;
181
- };
182
-
183
- const filterCctvDebugEvents = (
184
- events: readonly CctvDebugEvent[],
185
- keywords: readonly string[] = DEBUG_DEFAULT_FILTER_KEYWORDS,
186
- ): CctvDebugEvent[] =>
187
- events.filter(entry =>
188
- keywords.some(keyword => entry.event.includes(keyword)),
189
- );
190
-
191
- const summarizeCctvDebugEvents = (
192
- events: readonly CctvDebugEvent[],
193
- ): CctvDebugSummary => {
194
- const eventCounts: Record<string, number> = {};
195
- const connectionStateCounts: Record<string, number> = {};
196
- const reconnectReasons: Record<string, number> = {};
197
- const reconnectCallers: Record<string, number> = {};
198
- const reconnectStarts: CctvDebugReconnectStartSummary[] = [];
199
-
200
- for (const entry of events) {
201
- countBy(eventCounts, entry.event);
202
-
203
- if (entry.event === "connection-state:change") {
204
- countBy(connectionStateCounts, getPayloadString(entry.payload, "state"));
205
- }
206
-
207
- if (entry.event !== "reconnect-stream:start") continue;
208
-
209
- const reconnectReason = getPayloadString(entry.payload, "reconnectReason");
210
- const caller = getReconnectCaller(entry.payload);
211
-
212
- countBy(reconnectReasons, reconnectReason ?? "none");
213
- countBy(reconnectCallers, caller ?? "unknown");
214
- reconnectStarts.push({
215
- at: entry.at,
216
- caller,
217
- camId: getPayloadString(entry.payload, "camId"),
218
- connectionState: getPayloadString(entry.payload, "connectionState"),
219
- isPostConnectedReconnectReady: getPayloadBoolean(
220
- entry.payload,
221
- "isPostConnectedReconnectReady",
222
- ),
223
- reconnectReason,
224
- });
225
- }
226
-
227
- return {
228
- connectionStateCounts,
229
- eventCounts,
230
- first: events[0]?.at ?? null,
231
- generatedAt: new Date().toISOString(),
232
- last: events[events.length - 1]?.at ?? null,
233
- reconnectCallers,
234
- reconnectReasons,
235
- reconnectStarts,
236
- total: events.length,
237
- };
238
- };
239
-
240
- const decodeBase64UrlJson = (value: string): Record<string, unknown> | null => {
241
- try {
242
- const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
243
- const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
244
- const parsed = JSON.parse(atob(padded));
245
-
246
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
247
- ? (parsed as Record<string, unknown>)
248
- : null;
249
- } catch {
250
- return null;
251
- }
252
- };
253
-
254
- const getJwtStringClaim = (
255
- claims: Record<string, unknown> | null,
256
- key: string,
257
- ): string | null => {
258
- const value = claims?.[key];
259
- return typeof value === "string" ? value : null;
260
- };
261
-
262
- const getJwtExpiresAt = (
263
- claims: Record<string, unknown> | null,
264
- ): { expiresAt: string | null; expiresInSec: number | null } => {
265
- const exp = claims?.exp;
266
- if (typeof exp !== "number" || !Number.isFinite(exp)) {
267
- return { expiresAt: null, expiresInSec: null };
268
- }
269
-
270
- const expiresAtMs = exp * 1000;
271
- return {
272
- expiresAt: new Date(expiresAtMs).toISOString(),
273
- expiresInSec: Math.floor((expiresAtMs - Date.now()) / 1000),
274
- };
275
- };
276
-
277
- const stringifyCctvDebugEvent = (entry: CctvDebugEvent): string => {
278
- try {
279
- return JSON.stringify(entry);
280
- } catch {
281
- return JSON.stringify({
282
- at: entry.at,
283
- event: entry.event,
284
- level: entry.level,
285
- seq: entry.seq,
286
- source: entry.source,
287
- });
288
- }
289
- };
290
-
291
- /**
292
- * identity/stream key를 원문 대신 추적 가능한 hash label로 바꾼다.
293
- */
294
- export const getCctvDebugKeyLabel = (
295
- value: string | null | undefined,
296
- ): string | null => {
297
- if (!value) return null;
298
-
299
- let hash = 0;
300
- for (let i = 0; i < value.length; i += 1) {
301
- hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
302
- }
303
-
304
- return `hash:${hash.toString(36)}|len:${value.length}`;
305
- };
306
-
307
- /**
308
- * endpoint는 query string을 제거한 origin/path만 기록한다.
309
- */
310
- export const getCctvDebugUrlLabel = (
311
- value: string | null | undefined,
312
- ): string | null => {
313
- if (!value) return null;
314
-
315
- try {
316
- const url = new URL(value);
317
- return `${url.origin}${url.pathname}`;
318
- } catch {
319
- return value.split("?")[0] ?? null;
320
- }
321
- };
322
-
323
- /**
324
- * JWT 원문 대신 헤더/만료시각과 hash label만 기록한다.
325
- * @desc
326
- * token signature mismatch를 추적하기 위한 메타 정보이며, Bearer token 원문과 payload claim은
327
- * debug log에 남기지 않는다.
328
- */
329
- export const getCctvDebugJwtMetadata = (
330
- token: string | null | undefined,
331
- ): CctvDebugJwtMetadata | null => {
332
- if (!token) return null;
333
-
334
- const [headerPart, payloadPart] = token.split(".");
335
- const header = headerPart ? decodeBase64UrlJson(headerPart) : null;
336
- const payload = payloadPart ? decodeBase64UrlJson(payloadPart) : null;
337
- const { expiresAt, expiresInSec } = getJwtExpiresAt(payload);
338
-
339
- return {
340
- expiresAt,
341
- expiresInSec,
342
- headerAlg: getJwtStringClaim(header, "alg"),
343
- headerKid: getJwtStringClaim(header, "kid"),
344
- tokenLabel: getCctvDebugKeyLabel(token),
345
- tokenPartCount: token.split(".").length,
346
- };
347
- };
348
-
349
- /**
350
- * CCTV debug logging 활성화 여부를 확인한다.
351
- * @desc
352
- * production default는 off, localhost default는 ring buffer on이다.
353
- * console 출력은 별도 console flag를 켠 경우에만 활성화한다.
354
- */
355
- export const isCctvDebugEnabled = (): boolean => {
356
- if (!isBrowser()) return false;
357
-
358
- const queryOverride = getDebugQueryOverride(DEBUG_QUERY_KEYS);
359
- if (queryOverride !== null) return queryOverride;
360
-
361
- const consoleOverride = getDebugConsoleOverride();
362
- if (consoleOverride === true) return true;
363
-
364
- const storageOverride = getDebugStorageOverride(DEBUG_STORAGE_KEYS);
365
- if (storageOverride !== null) return storageOverride;
366
-
367
- return isLocalhostDebugDefaultEnabled();
368
- };
369
-
370
- const isCctvDebugConsoleEnabled = (): boolean => {
371
- if (!isBrowser()) return false;
372
-
373
- return getDebugConsoleOverride() === true;
374
- };
375
-
376
- /**
377
- * reconnect 호출 주체를 좁히기 위한 stack trace 일부를 만든다.
378
- */
379
- export const getCctvDebugStackTrace = (): string[] | null => {
380
- if (!isCctvDebugEnabled()) return null;
381
-
382
- const stack = new Error().stack;
383
- if (!stack) return null;
384
-
385
- return stack
386
- .split("\n")
387
- .slice(2, 8)
388
- .map(line => line.trim());
389
- };
390
-
391
- const getCctvDebugBuffer = (): CctvDebugBuffer | null => {
392
- if (!isCctvDebugEnabled()) return null;
393
-
394
- const existingBuffer = window.__UDS_CCTV_DEBUG__;
395
- if (existingBuffer) return existingBuffer;
396
-
397
- const buffer: CctvDebugBuffer = {
398
- enabled: true,
399
- events: [],
400
- limit: DEBUG_BUFFER_LIMIT,
401
- sequence: 0,
402
- clear: () => {
403
- buffer.events.splice(0, buffer.events.length);
404
- buffer.sequence = 0;
405
- },
406
- dump: () => [...buffer.events],
407
- filter: keywords => filterCctvDebugEvents(buffer.events, keywords),
408
- summary: () => summarizeCctvDebugEvents(buffer.events),
409
- };
410
-
411
- window.__UDS_CCTV_DEBUG__ = buffer;
412
- return buffer;
413
- };
414
-
415
- /**
416
- * CCTV runtime 진단 이벤트를 ring buffer와 console에 남긴다.
417
- * @desc
418
- * 토큰 값은 payload에 넣지 않는다. 이 logger는 debug flag가 켜진 경우에만 동작한다.
419
- */
420
- export const logCctvDebugEvent = ({
421
- event,
422
- level = "debug",
423
- payload,
424
- source,
425
- }: {
426
- event: string;
427
- level?: CctvDebugEventLevel;
428
- payload?: Record<string, unknown>;
429
- source: string;
430
- }): void => {
431
- const buffer = getCctvDebugBuffer();
432
- if (!buffer) return;
433
-
434
- const entry: CctvDebugEvent = {
435
- at: new Date().toISOString(),
436
- event,
437
- level,
438
- payload,
439
- seq: buffer.sequence + 1,
440
- source,
441
- };
442
-
443
- buffer.sequence = entry.seq;
444
- buffer.events.push(entry);
445
- if (buffer.events.length > buffer.limit) {
446
- buffer.events.splice(0, buffer.events.length - buffer.limit);
447
- }
448
-
449
- if (!isCctvDebugConsoleEnabled()) return;
450
-
451
- const logger =
452
- level === "error"
453
- ? console.error
454
- : level === "warn"
455
- ? console.warn
456
- : level === "info"
457
- ? console.info
458
- : console.debug;
459
-
460
- logger.call(console, `[UDS:CCTV] ${stringifyCctvDebugEvent(entry)}`);
461
- };