@uniai-fe/uds-templates 0.6.7 → 0.6.9
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 +1 -1
- package/src/cctv/hooks/streamRegistry.ts +94 -1
- package/src/cctv/hooks/useRtcStream.ts +297 -34
- package/src/cctv/types/hook.ts +5 -2
- package/src/cctv/types/props.ts +7 -4
- package/src/cctv/types/video-state.ts +4 -4
- package/src/cctv/utils/debug.ts +179 -0
- package/src/cctv/utils/video-state.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
|
|
4
|
+
import {
|
|
5
|
+
getCctvDebugKeyLabel,
|
|
6
|
+
getCctvDebugUrlLabel,
|
|
7
|
+
logCctvDebugEvent,
|
|
8
|
+
} from "../utils/debug";
|
|
4
9
|
|
|
5
10
|
interface CctvRtcStreamSnapshot {
|
|
6
11
|
connectionState: RTCPeerConnectionState;
|
|
@@ -47,6 +52,18 @@ const notifyEntry = (entry: CctvRtcStreamEntry) => {
|
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
const closeEntry = (entry: CctvRtcStreamEntry) => {
|
|
55
|
+
logCctvDebugEvent({
|
|
56
|
+
event: "entry:close",
|
|
57
|
+
payload: {
|
|
58
|
+
attachedVideoCount: entry.attachedVideos.size,
|
|
59
|
+
connectionState: entry.connectionState,
|
|
60
|
+
identityKey: getCctvDebugKeyLabel(entry.identityKey),
|
|
61
|
+
isStreaming: entry.isStreaming,
|
|
62
|
+
streamKey: getCctvDebugKeyLabel(entry.streamKey),
|
|
63
|
+
streamTrackCount: entry.stream?.getTracks().length ?? 0,
|
|
64
|
+
},
|
|
65
|
+
source: "streamRegistry",
|
|
66
|
+
});
|
|
50
67
|
entry.controller?.abort();
|
|
51
68
|
entry.controller = null;
|
|
52
69
|
entry.handle?.close();
|
|
@@ -110,6 +127,15 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
110
127
|
entries.clear();
|
|
111
128
|
},
|
|
112
129
|
closeByIdentity(identityKey, exceptStreamKey) {
|
|
130
|
+
logCctvDebugEvent({
|
|
131
|
+
event: "identity:close",
|
|
132
|
+
payload: {
|
|
133
|
+
entryCount: entries.size,
|
|
134
|
+
exceptStreamKey: getCctvDebugKeyLabel(exceptStreamKey),
|
|
135
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
136
|
+
},
|
|
137
|
+
source: "streamRegistry",
|
|
138
|
+
});
|
|
113
139
|
entries.forEach(entry => {
|
|
114
140
|
if (
|
|
115
141
|
entry.identityKey === identityKey &&
|
|
@@ -124,9 +150,22 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
124
150
|
},
|
|
125
151
|
start({ endpoint, identityKey, streamKey, token, video }) {
|
|
126
152
|
const existingEntry = entries.get(streamKey);
|
|
127
|
-
if (existingEntry)
|
|
153
|
+
if (existingEntry) {
|
|
154
|
+
logCctvDebugEvent({
|
|
155
|
+
event: "start:reuse-existing",
|
|
156
|
+
payload: {
|
|
157
|
+
connectionState: existingEntry.connectionState,
|
|
158
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
159
|
+
isStreaming: existingEntry.isStreaming,
|
|
160
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
161
|
+
},
|
|
162
|
+
source: "streamRegistry",
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
128
166
|
|
|
129
167
|
const controller = new AbortController();
|
|
168
|
+
const startedAt = Date.now();
|
|
130
169
|
const entry: CctvRtcStreamEntry = {
|
|
131
170
|
attachedVideos: new Set([video]),
|
|
132
171
|
connectionState: "connecting",
|
|
@@ -142,6 +181,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
142
181
|
|
|
143
182
|
entries.set(streamKey, entry);
|
|
144
183
|
attachVideo(video, null);
|
|
184
|
+
logCctvDebugEvent({
|
|
185
|
+
event: "start:create-entry",
|
|
186
|
+
payload: {
|
|
187
|
+
attachedVideoCount: entry.attachedVideos.size,
|
|
188
|
+
endpoint: getCctvDebugUrlLabel(endpoint),
|
|
189
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
190
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
191
|
+
},
|
|
192
|
+
source: "streamRegistry",
|
|
193
|
+
});
|
|
145
194
|
|
|
146
195
|
startWhepStream({
|
|
147
196
|
endpoint,
|
|
@@ -150,10 +199,33 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
150
199
|
signal: controller.signal,
|
|
151
200
|
onConnectionStateChange: state => {
|
|
152
201
|
entry.connectionState = state;
|
|
202
|
+
logCctvDebugEvent({
|
|
203
|
+
event: "connection-state:change",
|
|
204
|
+
payload: {
|
|
205
|
+
elapsedMs: Date.now() - startedAt,
|
|
206
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
207
|
+
state,
|
|
208
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
209
|
+
},
|
|
210
|
+
source: "streamRegistry",
|
|
211
|
+
});
|
|
153
212
|
notifyEntry(entry);
|
|
154
213
|
},
|
|
155
214
|
onTrack: event => {
|
|
156
215
|
entry.stream = event.streams[0] ?? null;
|
|
216
|
+
logCctvDebugEvent({
|
|
217
|
+
event: "track:received",
|
|
218
|
+
payload: {
|
|
219
|
+
elapsedMs: Date.now() - startedAt,
|
|
220
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
221
|
+
streamId: entry.stream?.id ?? null,
|
|
222
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
223
|
+
trackId: event.track.id,
|
|
224
|
+
trackKind: event.track.kind,
|
|
225
|
+
trackReadyState: event.track.readyState,
|
|
226
|
+
},
|
|
227
|
+
source: "streamRegistry",
|
|
228
|
+
});
|
|
157
229
|
entry.attachedVideos.forEach(attachedVideo =>
|
|
158
230
|
attachVideo(attachedVideo, entry.stream),
|
|
159
231
|
);
|
|
@@ -163,6 +235,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
163
235
|
.then(handle => {
|
|
164
236
|
entry.handle = handle;
|
|
165
237
|
entry.isStreaming = false;
|
|
238
|
+
logCctvDebugEvent({
|
|
239
|
+
event: "start:resolved",
|
|
240
|
+
level: "info",
|
|
241
|
+
payload: {
|
|
242
|
+
elapsedMs: Date.now() - startedAt,
|
|
243
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
244
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
245
|
+
},
|
|
246
|
+
source: "streamRegistry",
|
|
247
|
+
});
|
|
166
248
|
notifyEntry(entry);
|
|
167
249
|
})
|
|
168
250
|
.catch(error => {
|
|
@@ -174,6 +256,17 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
174
256
|
error instanceof Error
|
|
175
257
|
? error.message
|
|
176
258
|
: "스트림 연결에 실패했습니다.";
|
|
259
|
+
logCctvDebugEvent({
|
|
260
|
+
event: "start:rejected",
|
|
261
|
+
level: "error",
|
|
262
|
+
payload: {
|
|
263
|
+
elapsedMs: Date.now() - startedAt,
|
|
264
|
+
errorMessage: entry.streamError,
|
|
265
|
+
identityKey: getCctvDebugKeyLabel(identityKey),
|
|
266
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
267
|
+
},
|
|
268
|
+
source: "streamRegistry",
|
|
269
|
+
});
|
|
177
270
|
entry.attachedVideos.forEach(attachedVideo =>
|
|
178
271
|
attachVideo(attachedVideo, null),
|
|
179
272
|
);
|
|
@@ -19,27 +19,73 @@ 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";
|
|
22
27
|
import { getIsLive } from "../utils/video-state";
|
|
23
28
|
import { useFormContext, useWatch } from "react-hook-form";
|
|
24
29
|
|
|
25
30
|
const AUTO_RECONNECT_INTERVAL_MS = 3000;
|
|
26
|
-
const
|
|
27
|
-
const
|
|
31
|
+
const POST_CONNECTED_RECONNECT_GRACE_MS = 5000;
|
|
32
|
+
const POST_CONNECTED_RECONNECT_STAGGER_MS = 5000;
|
|
28
33
|
const TOKEN_EXPIRY_SAFETY_MS = 30 * 1000;
|
|
29
34
|
|
|
35
|
+
type CctvRtcReconnectReason =
|
|
36
|
+
| "tokenError"
|
|
37
|
+
| "streamError"
|
|
38
|
+
| "failed"
|
|
39
|
+
| "disconnected";
|
|
40
|
+
|
|
41
|
+
const DISPLAY_CONNECTED_DURING_GRACE_STATES = new Set<RTCPeerConnectionState>([
|
|
42
|
+
"disconnected",
|
|
43
|
+
"failed",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 같은 회사/카메라 묶음이 동시에 재연결하지 않도록 identity 기반 지연값을 만든다.
|
|
48
|
+
* @desc
|
|
49
|
+
* 이 값은 보안용 난수가 아니라 UI/네트워크 부하를 분산하기 위한 안정 해시다.
|
|
50
|
+
*/
|
|
30
51
|
const getStableStaggerMs = (value: string): number => {
|
|
31
52
|
let hash = 0;
|
|
32
53
|
|
|
33
54
|
for (let i = 0; i < value.length; i += 1) {
|
|
34
55
|
hash =
|
|
35
|
-
(hash * 31 + value.charCodeAt(i)) %
|
|
56
|
+
(hash * 31 + value.charCodeAt(i)) % POST_CONNECTED_RECONNECT_STAGGER_MS;
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
return hash;
|
|
39
60
|
};
|
|
40
61
|
|
|
41
|
-
|
|
42
|
-
|
|
62
|
+
/**
|
|
63
|
+
* 최초 연결 이후 끊김/실패가 감지됐을 때 자동 재연결 노출 전 대기시간을 계산한다.
|
|
64
|
+
*/
|
|
65
|
+
const getPostConnectedReconnectDelayMs = (delayKey: string): number =>
|
|
66
|
+
POST_CONNECTED_RECONNECT_GRACE_MS + getStableStaggerMs(delayKey);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 재연결이 필요한 장애 사유를 UDS 내부 기준으로 정규화한다.
|
|
70
|
+
* @desc
|
|
71
|
+
* `closed`는 `streamRegistry.closeByIdentity()`처럼 UDS가 의도적으로 닫을 때도 발생하므로
|
|
72
|
+
* 자동 재연결 사유에 포함하지 않는다.
|
|
73
|
+
*/
|
|
74
|
+
const getPostConnectedReconnectReason = ({
|
|
75
|
+
connectionState,
|
|
76
|
+
isTokenError,
|
|
77
|
+
streamError,
|
|
78
|
+
}: {
|
|
79
|
+
connectionState: RTCPeerConnectionState;
|
|
80
|
+
isTokenError: boolean;
|
|
81
|
+
streamError: string | null;
|
|
82
|
+
}): CctvRtcReconnectReason | null => {
|
|
83
|
+
if (isTokenError) return "tokenError";
|
|
84
|
+
if (streamError) return "streamError";
|
|
85
|
+
if (connectionState === "failed") return "failed";
|
|
86
|
+
if (connectionState === "disconnected") return "disconnected";
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
43
89
|
|
|
44
90
|
const decodeBase64Url = (value: string): string | null => {
|
|
45
91
|
try {
|
|
@@ -102,7 +148,7 @@ const canUseTokenForNewConnection = ({
|
|
|
102
148
|
* isStreaming, // 현재 스트림 연결 절차가 진행 중인지 여부
|
|
103
149
|
* isTokenLoading, // 토큰 발급 요청이 진행 중인지 여부
|
|
104
150
|
* isTokenError, // 토큰 발급 요청이 실패했는지 여부
|
|
105
|
-
* canReconnect, // 재연결
|
|
151
|
+
* canReconnect, // UDS 내부 grace/stagger 이후 재연결 호출이 가능한지 여부
|
|
106
152
|
* refetchToken, // 토큰 발급을 재시도하는 함수
|
|
107
153
|
* reconnectStream, // 재연결 trigger 함수
|
|
108
154
|
* }
|
|
@@ -137,7 +183,7 @@ export function useCctvRtcStream({
|
|
|
137
183
|
// 현재 스트림 연결 절차가 진행 중인지 여부.
|
|
138
184
|
const [isStreaming, setStreaming] = useState(false);
|
|
139
185
|
const [hasConnected, setHasConnected] = useState(false);
|
|
140
|
-
const [
|
|
186
|
+
const [isPostConnectedReconnectReady, setPostConnectedReconnectReady] =
|
|
141
187
|
useState(false);
|
|
142
188
|
|
|
143
189
|
// react-hook-form 컨텍스트에서 token username과 WHEP username override를 추적한다.
|
|
@@ -195,37 +241,106 @@ export function useCctvRtcStream({
|
|
|
195
241
|
return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
|
|
196
242
|
}, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
|
|
197
243
|
|
|
244
|
+
const debugBasePayload = useMemo(
|
|
245
|
+
() => ({
|
|
246
|
+
camId: cam?.cam_id,
|
|
247
|
+
companyId: cam?.company_id,
|
|
248
|
+
streamIdentityKey: getCctvDebugKeyLabel(streamIdentityKey),
|
|
249
|
+
}),
|
|
250
|
+
[cam?.cam_id, cam?.company_id, streamIdentityKey],
|
|
251
|
+
);
|
|
252
|
+
|
|
198
253
|
useEffect(() => {
|
|
254
|
+
logCctvDebugEvent({
|
|
255
|
+
event: "identity:reset-has-connected",
|
|
256
|
+
payload: debugBasePayload,
|
|
257
|
+
source: "useRtcStream",
|
|
258
|
+
});
|
|
199
259
|
setHasConnected(false);
|
|
200
|
-
}, [streamIdentityKey]);
|
|
260
|
+
}, [debugBasePayload, streamIdentityKey]);
|
|
261
|
+
|
|
262
|
+
const reconnectReason = useMemo(
|
|
263
|
+
() =>
|
|
264
|
+
getPostConnectedReconnectReason({
|
|
265
|
+
connectionState,
|
|
266
|
+
isTokenError,
|
|
267
|
+
streamError,
|
|
268
|
+
}),
|
|
269
|
+
[connectionState, isTokenError, streamError],
|
|
270
|
+
);
|
|
201
271
|
|
|
202
272
|
useEffect(() => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
273
|
+
const canStartReconnectTimer =
|
|
274
|
+
hasConnected &&
|
|
275
|
+
Boolean(streamIdentityKey) &&
|
|
276
|
+
Boolean(reconnectReason) &&
|
|
277
|
+
!isTokenLoading &&
|
|
278
|
+
!isStreaming;
|
|
279
|
+
|
|
280
|
+
if (!canStartReconnectTimer) {
|
|
281
|
+
setPostConnectedReconnectReady(false);
|
|
209
282
|
return;
|
|
210
283
|
}
|
|
211
284
|
|
|
212
|
-
|
|
285
|
+
setPostConnectedReconnectReady(false);
|
|
286
|
+
const reconnectDelayMs = getPostConnectedReconnectDelayMs(
|
|
287
|
+
`${streamIdentityKey}|${reconnectReason}`,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
logCctvDebugEvent({
|
|
291
|
+
event: "reconnect-gate:timer-start",
|
|
292
|
+
payload: {
|
|
293
|
+
...debugBasePayload,
|
|
294
|
+
isStreaming,
|
|
295
|
+
isTokenLoading,
|
|
296
|
+
reconnectDelayMs,
|
|
297
|
+
reconnectReason,
|
|
298
|
+
},
|
|
299
|
+
source: "useRtcStream",
|
|
300
|
+
});
|
|
213
301
|
|
|
214
302
|
const timeout = setTimeout(() => {
|
|
215
|
-
|
|
216
|
-
|
|
303
|
+
setPostConnectedReconnectReady(true);
|
|
304
|
+
logCctvDebugEvent({
|
|
305
|
+
event: "reconnect-gate:ready",
|
|
306
|
+
payload: {
|
|
307
|
+
...debugBasePayload,
|
|
308
|
+
reconnectDelayMs,
|
|
309
|
+
reconnectReason,
|
|
310
|
+
},
|
|
311
|
+
source: "useRtcStream",
|
|
312
|
+
});
|
|
313
|
+
}, reconnectDelayMs);
|
|
217
314
|
|
|
218
315
|
return () => {
|
|
219
316
|
clearTimeout(timeout);
|
|
317
|
+
logCctvDebugEvent({
|
|
318
|
+
event: "reconnect-gate:timer-cancel",
|
|
319
|
+
payload: {
|
|
320
|
+
...debugBasePayload,
|
|
321
|
+
reconnectDelayMs,
|
|
322
|
+
reconnectReason,
|
|
323
|
+
},
|
|
324
|
+
source: "useRtcStream",
|
|
325
|
+
});
|
|
220
326
|
};
|
|
221
|
-
}, [
|
|
327
|
+
}, [
|
|
328
|
+
debugBasePayload,
|
|
329
|
+
hasConnected,
|
|
330
|
+
isStreaming,
|
|
331
|
+
isTokenLoading,
|
|
332
|
+
reconnectReason,
|
|
333
|
+
streamIdentityKey,
|
|
334
|
+
]);
|
|
222
335
|
|
|
223
|
-
const
|
|
224
|
-
connectionState === "disconnected" &&
|
|
336
|
+
const shouldPreserveConnectedDisplay =
|
|
225
337
|
hasConnected &&
|
|
226
|
-
!
|
|
227
|
-
|
|
228
|
-
|
|
338
|
+
!isPostConnectedReconnectReady &&
|
|
339
|
+
DISPLAY_CONNECTED_DURING_GRACE_STATES.has(connectionState);
|
|
340
|
+
|
|
341
|
+
const displayConnectionState = shouldPreserveConnectedDisplay
|
|
342
|
+
? "connected"
|
|
343
|
+
: connectionState;
|
|
229
344
|
|
|
230
345
|
const hasReusableRegistryStream = useMemo(() => {
|
|
231
346
|
if (!streamKeyCandidate) return false;
|
|
@@ -276,12 +391,23 @@ export function useCctvRtcStream({
|
|
|
276
391
|
}
|
|
277
392
|
|
|
278
393
|
staleTokenRefreshKeyRef.current = streamKeyCandidate;
|
|
394
|
+
logCctvDebugEvent({
|
|
395
|
+
event: "token:stale-refetch",
|
|
396
|
+
payload: {
|
|
397
|
+
...debugBasePayload,
|
|
398
|
+
dataUpdatedAt: tokenQuery.dataUpdatedAt,
|
|
399
|
+
streamKey: getCctvDebugKeyLabel(streamKeyCandidate),
|
|
400
|
+
},
|
|
401
|
+
source: "useRtcStream",
|
|
402
|
+
});
|
|
279
403
|
void refetchRtcToken();
|
|
280
404
|
}, [
|
|
281
405
|
canUseTokenForStream,
|
|
406
|
+
debugBasePayload,
|
|
282
407
|
refetchRtcToken,
|
|
283
408
|
streamKeyCandidate,
|
|
284
409
|
tokenQuery.data?.token,
|
|
410
|
+
tokenQuery.dataUpdatedAt,
|
|
285
411
|
tokenQuery.isError,
|
|
286
412
|
tokenQuery.isFetching,
|
|
287
413
|
]);
|
|
@@ -292,6 +418,15 @@ export function useCctvRtcStream({
|
|
|
292
418
|
|
|
293
419
|
// 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
|
|
294
420
|
if (tokenQuery.isError || !cam?.cam_online) {
|
|
421
|
+
logCctvDebugEvent({
|
|
422
|
+
event: "stream-effect:cleanup-token-or-offline",
|
|
423
|
+
payload: {
|
|
424
|
+
...debugBasePayload,
|
|
425
|
+
camOnline: cam?.cam_online,
|
|
426
|
+
isTokenError: tokenQuery.isError,
|
|
427
|
+
},
|
|
428
|
+
source: "useRtcStream",
|
|
429
|
+
});
|
|
295
430
|
if (streamIdentityKey) {
|
|
296
431
|
streamRegistry.closeByIdentity(streamIdentityKey);
|
|
297
432
|
}
|
|
@@ -310,12 +445,30 @@ export function useCctvRtcStream({
|
|
|
310
445
|
activeStreamIdentityKeyRef.current === streamIdentityKey &&
|
|
311
446
|
activeStreamKeyRef.current !== streamKey
|
|
312
447
|
) {
|
|
448
|
+
logCctvDebugEvent({
|
|
449
|
+
event: "stream-effect:close-previous-identity",
|
|
450
|
+
payload: {
|
|
451
|
+
...debugBasePayload,
|
|
452
|
+
activeStreamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
|
|
453
|
+
nextStreamKey: getCctvDebugKeyLabel(streamKey),
|
|
454
|
+
},
|
|
455
|
+
source: "useRtcStream",
|
|
456
|
+
});
|
|
313
457
|
streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
|
|
314
458
|
}
|
|
315
459
|
|
|
316
460
|
activeStreamKeyRef.current = streamKey;
|
|
317
461
|
activeStreamIdentityKeyRef.current = streamIdentityKey;
|
|
318
462
|
|
|
463
|
+
logCctvDebugEvent({
|
|
464
|
+
event: "stream-effect:start",
|
|
465
|
+
payload: {
|
|
466
|
+
...debugBasePayload,
|
|
467
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
468
|
+
},
|
|
469
|
+
source: "useRtcStream",
|
|
470
|
+
});
|
|
471
|
+
|
|
319
472
|
streamRegistry.start({
|
|
320
473
|
streamKey,
|
|
321
474
|
identityKey: streamIdentityKey,
|
|
@@ -329,7 +482,17 @@ export function useCctvRtcStream({
|
|
|
329
482
|
setConnectionState(snapshot.connectionState);
|
|
330
483
|
setStreamError(snapshot.streamError);
|
|
331
484
|
setStreaming(snapshot.isStreaming);
|
|
332
|
-
if (snapshot.connectionState === "connected")
|
|
485
|
+
if (snapshot.connectionState === "connected") {
|
|
486
|
+
logCctvDebugEvent({
|
|
487
|
+
event: "stream-effect:has-connected",
|
|
488
|
+
payload: {
|
|
489
|
+
...debugBasePayload,
|
|
490
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
491
|
+
},
|
|
492
|
+
source: "useRtcStream",
|
|
493
|
+
});
|
|
494
|
+
setHasConnected(true);
|
|
495
|
+
}
|
|
333
496
|
if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
|
|
334
497
|
currentVideo.srcObject = snapshot.stream;
|
|
335
498
|
}
|
|
@@ -346,10 +509,19 @@ export function useCctvRtcStream({
|
|
|
346
509
|
|
|
347
510
|
// effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
|
|
348
511
|
return () => {
|
|
512
|
+
logCctvDebugEvent({
|
|
513
|
+
event: "stream-effect:detach-video",
|
|
514
|
+
payload: {
|
|
515
|
+
...debugBasePayload,
|
|
516
|
+
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
517
|
+
},
|
|
518
|
+
source: "useRtcStream",
|
|
519
|
+
});
|
|
349
520
|
unsubscribe();
|
|
350
521
|
detachVideo();
|
|
351
522
|
};
|
|
352
523
|
}, [
|
|
524
|
+
debugBasePayload,
|
|
353
525
|
endpoint,
|
|
354
526
|
streamIdentityKey,
|
|
355
527
|
streamKey,
|
|
@@ -359,10 +531,56 @@ export function useCctvRtcStream({
|
|
|
359
531
|
cam?.cam_online,
|
|
360
532
|
]);
|
|
361
533
|
|
|
534
|
+
const debugReconnectStateRef = useRef({
|
|
535
|
+
connectionState,
|
|
536
|
+
debugBasePayload,
|
|
537
|
+
isPostConnectedReconnectReady,
|
|
538
|
+
reconnectReason,
|
|
539
|
+
streamError,
|
|
540
|
+
});
|
|
541
|
+
debugReconnectStateRef.current = {
|
|
542
|
+
connectionState,
|
|
543
|
+
debugBasePayload,
|
|
544
|
+
isPostConnectedReconnectReady,
|
|
545
|
+
reconnectReason,
|
|
546
|
+
streamError,
|
|
547
|
+
};
|
|
548
|
+
|
|
362
549
|
const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
|
|
363
550
|
async options => {
|
|
551
|
+
const debugState = debugReconnectStateRef.current;
|
|
552
|
+
logCctvDebugEvent({
|
|
553
|
+
event: "reconnect-stream:start",
|
|
554
|
+
payload: {
|
|
555
|
+
...debugState.debugBasePayload,
|
|
556
|
+
connectionState: debugState.connectionState,
|
|
557
|
+
callerStack: getCctvDebugStackTrace(),
|
|
558
|
+
isPostConnectedReconnectReady:
|
|
559
|
+
debugState.isPostConnectedReconnectReady,
|
|
560
|
+
reconnectReason: debugState.reconnectReason,
|
|
561
|
+
streamError: debugState.streamError,
|
|
562
|
+
},
|
|
563
|
+
source: "useRtcStream",
|
|
564
|
+
});
|
|
364
565
|
const result = await refetchRtcToken(options);
|
|
566
|
+
const resultDebugState = debugReconnectStateRef.current;
|
|
567
|
+
logCctvDebugEvent({
|
|
568
|
+
event: "reconnect-stream:token-result",
|
|
569
|
+
level: result.isSuccess ? "info" : "warn",
|
|
570
|
+
payload: {
|
|
571
|
+
...resultDebugState.debugBasePayload,
|
|
572
|
+
isError: result.isError,
|
|
573
|
+
isSuccess: result.isSuccess,
|
|
574
|
+
status: result.status,
|
|
575
|
+
},
|
|
576
|
+
source: "useRtcStream",
|
|
577
|
+
});
|
|
365
578
|
if (result.isSuccess && streamIdentityKey) {
|
|
579
|
+
logCctvDebugEvent({
|
|
580
|
+
event: "reconnect-stream:close-identity",
|
|
581
|
+
payload: resultDebugState.debugBasePayload,
|
|
582
|
+
source: "useRtcStream",
|
|
583
|
+
});
|
|
366
584
|
streamRegistry.closeByIdentity(streamIdentityKey);
|
|
367
585
|
}
|
|
368
586
|
return result;
|
|
@@ -375,20 +593,15 @@ export function useCctvRtcStream({
|
|
|
375
593
|
if (!hasConnected) return false;
|
|
376
594
|
if (!streamIdentityKey) return false;
|
|
377
595
|
if (isTokenLoading || isStreaming) return false;
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
if (connectionState === "disconnected") return isDisconnectedReconnectReady;
|
|
381
|
-
if (connectionState === "closed") return true;
|
|
382
|
-
return false;
|
|
596
|
+
if (!reconnectReason) return false;
|
|
597
|
+
return isPostConnectedReconnectReady;
|
|
383
598
|
}, [
|
|
384
599
|
cam?.cam_online,
|
|
385
|
-
connectionState,
|
|
386
600
|
hasConnected,
|
|
387
|
-
|
|
601
|
+
isPostConnectedReconnectReady,
|
|
388
602
|
isStreaming,
|
|
389
|
-
isTokenError,
|
|
390
603
|
isTokenLoading,
|
|
391
|
-
|
|
604
|
+
reconnectReason,
|
|
392
605
|
streamIdentityKey,
|
|
393
606
|
]);
|
|
394
607
|
|
|
@@ -407,6 +620,16 @@ export function useCctvRtcStream({
|
|
|
407
620
|
return;
|
|
408
621
|
|
|
409
622
|
lastAutoReconnectAtRef.current = now;
|
|
623
|
+
logCctvDebugEvent({
|
|
624
|
+
event: "focus:auto-reconnect",
|
|
625
|
+
payload: {
|
|
626
|
+
...debugBasePayload,
|
|
627
|
+
canReconnect,
|
|
628
|
+
connectionState,
|
|
629
|
+
reconnectReason,
|
|
630
|
+
},
|
|
631
|
+
source: "useRtcStream",
|
|
632
|
+
});
|
|
410
633
|
void reconnectStream();
|
|
411
634
|
};
|
|
412
635
|
|
|
@@ -417,7 +640,13 @@ export function useCctvRtcStream({
|
|
|
417
640
|
window.removeEventListener("focus", reconnectOnFocus);
|
|
418
641
|
document.removeEventListener("visibilitychange", reconnectOnFocus);
|
|
419
642
|
};
|
|
420
|
-
}, [
|
|
643
|
+
}, [
|
|
644
|
+
canReconnect,
|
|
645
|
+
connectionState,
|
|
646
|
+
debugBasePayload,
|
|
647
|
+
reconnectReason,
|
|
648
|
+
reconnectStream,
|
|
649
|
+
]);
|
|
421
650
|
|
|
422
651
|
const liveState = useMemo(
|
|
423
652
|
() =>
|
|
@@ -441,6 +670,40 @@ export function useCctvRtcStream({
|
|
|
441
670
|
],
|
|
442
671
|
);
|
|
443
672
|
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
logCctvDebugEvent({
|
|
675
|
+
event: "hook-state:update",
|
|
676
|
+
payload: {
|
|
677
|
+
...debugBasePayload,
|
|
678
|
+
canReconnect,
|
|
679
|
+
connectionState,
|
|
680
|
+
displayConnectionState,
|
|
681
|
+
hasConnected,
|
|
682
|
+
isPostConnectedReconnectReady,
|
|
683
|
+
isStreaming,
|
|
684
|
+
isTokenError,
|
|
685
|
+
isTokenLoading,
|
|
686
|
+
liveState,
|
|
687
|
+
reconnectReason,
|
|
688
|
+
streamError,
|
|
689
|
+
},
|
|
690
|
+
source: "useRtcStream",
|
|
691
|
+
});
|
|
692
|
+
}, [
|
|
693
|
+
canReconnect,
|
|
694
|
+
connectionState,
|
|
695
|
+
debugBasePayload,
|
|
696
|
+
displayConnectionState,
|
|
697
|
+
hasConnected,
|
|
698
|
+
isPostConnectedReconnectReady,
|
|
699
|
+
isStreaming,
|
|
700
|
+
isTokenError,
|
|
701
|
+
isTokenLoading,
|
|
702
|
+
liveState,
|
|
703
|
+
reconnectReason,
|
|
704
|
+
streamError,
|
|
705
|
+
]);
|
|
706
|
+
|
|
444
707
|
useEffect(() => {
|
|
445
708
|
const camId = cam?.cam_id;
|
|
446
709
|
if (!camId) return;
|
package/src/cctv/types/hook.ts
CHANGED
|
@@ -88,7 +88,7 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
|
|
|
88
88
|
* @property {boolean} isStreaming startWhepStream 진행 여부
|
|
89
89
|
* @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
|
|
90
90
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
91
|
-
* @property {boolean} canReconnect 재연결
|
|
91
|
+
* @property {boolean} canReconnect UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
92
92
|
* @property {() => Promise<void>} refetchToken 토큰 재발급 함수
|
|
93
93
|
* @property {() => Promise<QueryObserverResult<API_Res_CctvRtcToken>>} reconnectStream 재연결 trigger 함수
|
|
94
94
|
*/
|
|
@@ -98,7 +98,10 @@ export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
|
|
|
98
98
|
*/
|
|
99
99
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
100
100
|
/**
|
|
101
|
-
* 재연결
|
|
101
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
102
|
+
* @desc
|
|
103
|
+
* 스트림 장애 감지 즉시 true가 되는 플래그가 아니라,
|
|
104
|
+
* 서비스 overlay가 reconnectStream()을 호출해도 되는 시점에 true가 된다.
|
|
102
105
|
*/
|
|
103
106
|
canReconnect: boolean;
|
|
104
107
|
/**
|
package/src/cctv/types/props.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { CctvRtcReconnectTrigger } from "./hook";
|
|
|
9
9
|
* @property {boolean | undefined} [isError] 영상 에러 상태 여부
|
|
10
10
|
* @property {boolean | undefined} [isLive] 영상 live 상태 여부
|
|
11
11
|
* @property {React.ReactNode} [overlayMessage] 기본 안내/에러 메시지 콘텐츠
|
|
12
|
-
* @property {boolean | undefined} [canReconnect] 재연결
|
|
12
|
+
* @property {boolean | undefined} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
13
13
|
* @property {CctvRtcReconnectTrigger | undefined} [reconnectStream] 재연결 trigger 함수
|
|
14
14
|
*/
|
|
15
15
|
export interface CctvVideoRenderOverlayContext {
|
|
@@ -38,7 +38,10 @@ export interface CctvVideoRenderOverlayContext {
|
|
|
38
38
|
*/
|
|
39
39
|
overlayMessage?: React.ReactNode;
|
|
40
40
|
/**
|
|
41
|
-
* 재연결
|
|
41
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
42
|
+
* @desc
|
|
43
|
+
* custom overlay에서 자동 재연결을 구현할 때 이 값이 true인 경우에만
|
|
44
|
+
* reconnectStream()을 호출해야 한다.
|
|
42
45
|
*/
|
|
43
46
|
canReconnect?: boolean;
|
|
44
47
|
/**
|
|
@@ -126,7 +129,7 @@ export interface CctvVideoOverlayFooterProps {
|
|
|
126
129
|
* @property {boolean} [isError] 에러 상태 여부
|
|
127
130
|
* @property {boolean} [isLive] 영상이 정상적으로 재생 중인지 여부
|
|
128
131
|
* @property {React.ReactNode} [overlayMessage] 에러/안내 메시지 콘텐츠
|
|
129
|
-
* @property {boolean} [canReconnect] 재연결
|
|
132
|
+
* @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
130
133
|
* @property {CctvRtcReconnectTrigger} [reconnectStream] 재연결 trigger 함수
|
|
131
134
|
*/
|
|
132
135
|
export interface CctvVideoStateProps {
|
|
@@ -143,7 +146,7 @@ export interface CctvVideoStateProps {
|
|
|
143
146
|
*/
|
|
144
147
|
overlayMessage?: React.ReactNode;
|
|
145
148
|
/**
|
|
146
|
-
* 재연결
|
|
149
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
147
150
|
*/
|
|
148
151
|
canReconnect?: boolean;
|
|
149
152
|
/**
|
|
@@ -11,7 +11,7 @@ import type { UseCctvRtcStreamError, UseCctvRtcStreamState } from "./hook";
|
|
|
11
11
|
* @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
|
|
12
12
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
13
13
|
* @property {string | null} streamError 스트림 오류 메시지
|
|
14
|
-
* @property {boolean} [canReconnect] 재연결
|
|
14
|
+
* @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
15
15
|
*/
|
|
16
16
|
export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
|
|
17
17
|
/**
|
|
@@ -27,7 +27,7 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
|
|
|
27
27
|
*/
|
|
28
28
|
isFetching?: boolean;
|
|
29
29
|
/**
|
|
30
|
-
* 재연결
|
|
30
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
31
31
|
*/
|
|
32
32
|
canReconnect?: boolean;
|
|
33
33
|
}
|
|
@@ -53,7 +53,7 @@ export interface CctvVideoLiveParams extends UseCctvRtcStreamState {
|
|
|
53
53
|
* @property {CctvCompanyCameraData} [cam] 카메라 데이터
|
|
54
54
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
55
55
|
* @property {string | null} streamError 스트림 오류 메시지
|
|
56
|
-
* @property {boolean} [canReconnect] 재연결
|
|
56
|
+
* @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
57
57
|
*/
|
|
58
58
|
export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
|
|
59
59
|
/**
|
|
@@ -61,7 +61,7 @@ export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
|
|
|
61
61
|
*/
|
|
62
62
|
cam?: CctvCompanyCameraData;
|
|
63
63
|
/**
|
|
64
|
-
* 재연결
|
|
64
|
+
* UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
65
65
|
*/
|
|
66
66
|
canReconnect?: boolean;
|
|
67
67
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
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 CctvDebugBuffer {
|
|
15
|
+
clear: () => void;
|
|
16
|
+
dump: () => CctvDebugEvent[];
|
|
17
|
+
enabled: true;
|
|
18
|
+
events: CctvDebugEvent[];
|
|
19
|
+
limit: number;
|
|
20
|
+
sequence: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
declare global {
|
|
24
|
+
interface Window {
|
|
25
|
+
__UDS_CCTV_DEBUG__?: CctvDebugBuffer;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEBUG_QUERY_KEYS = ["udsCctvDebug", "cctvDebug"] as const;
|
|
30
|
+
const DEBUG_STORAGE_KEYS = ["uds:cctv:debug", "UDS_CCTV_DEBUG"] as const;
|
|
31
|
+
const DEBUG_ENABLED_VALUES = new Set(["1", "true", "yes", "on", "debug"]);
|
|
32
|
+
const DEBUG_BUFFER_LIMIT = 1000;
|
|
33
|
+
|
|
34
|
+
const isBrowser = (): boolean =>
|
|
35
|
+
typeof window !== "undefined" && typeof document !== "undefined";
|
|
36
|
+
|
|
37
|
+
const isDebugValueEnabled = (value: string | null): boolean =>
|
|
38
|
+
value === "" ||
|
|
39
|
+
(typeof value === "string" && DEBUG_ENABLED_VALUES.has(value));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* identity/stream key를 원문 대신 추적 가능한 hash label로 바꾼다.
|
|
43
|
+
*/
|
|
44
|
+
export const getCctvDebugKeyLabel = (
|
|
45
|
+
value: string | null | undefined,
|
|
46
|
+
): string | null => {
|
|
47
|
+
if (!value) return null;
|
|
48
|
+
|
|
49
|
+
let hash = 0;
|
|
50
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
51
|
+
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `hash:${hash.toString(36)}|len:${value.length}`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* endpoint는 query string을 제거한 origin/path만 기록한다.
|
|
59
|
+
*/
|
|
60
|
+
export const getCctvDebugUrlLabel = (
|
|
61
|
+
value: string | null | undefined,
|
|
62
|
+
): string | null => {
|
|
63
|
+
if (!value) return null;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(value);
|
|
67
|
+
return `${url.origin}${url.pathname}`;
|
|
68
|
+
} catch {
|
|
69
|
+
return value.split("?")[0] ?? null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* CCTV debug logging 활성화 여부를 확인한다.
|
|
75
|
+
* @desc
|
|
76
|
+
* production default는 off다. `?udsCctvDebug=1` 또는
|
|
77
|
+
* `localStorage.setItem("uds:cctv:debug", "1")`로 runtime에서 켤 수 있다.
|
|
78
|
+
*/
|
|
79
|
+
export const isCctvDebugEnabled = (): boolean => {
|
|
80
|
+
if (!isBrowser()) return false;
|
|
81
|
+
|
|
82
|
+
const query = new URLSearchParams(window.location.search);
|
|
83
|
+
for (const key of DEBUG_QUERY_KEYS) {
|
|
84
|
+
if (query.has(key) && isDebugValueEnabled(query.get(key))) return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const key of DEBUG_STORAGE_KEYS) {
|
|
88
|
+
try {
|
|
89
|
+
if (isDebugValueEnabled(window.localStorage.getItem(key))) return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* reconnect 호출 주체를 좁히기 위한 stack trace 일부를 만든다.
|
|
100
|
+
*/
|
|
101
|
+
export const getCctvDebugStackTrace = (): string[] | null => {
|
|
102
|
+
if (!isCctvDebugEnabled()) return null;
|
|
103
|
+
|
|
104
|
+
const stack = new Error().stack;
|
|
105
|
+
if (!stack) return null;
|
|
106
|
+
|
|
107
|
+
return stack
|
|
108
|
+
.split("\n")
|
|
109
|
+
.slice(2, 8)
|
|
110
|
+
.map(line => line.trim());
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const getCctvDebugBuffer = (): CctvDebugBuffer | null => {
|
|
114
|
+
if (!isCctvDebugEnabled()) return null;
|
|
115
|
+
|
|
116
|
+
const existingBuffer = window.__UDS_CCTV_DEBUG__;
|
|
117
|
+
if (existingBuffer) return existingBuffer;
|
|
118
|
+
|
|
119
|
+
const buffer: CctvDebugBuffer = {
|
|
120
|
+
enabled: true,
|
|
121
|
+
events: [],
|
|
122
|
+
limit: DEBUG_BUFFER_LIMIT,
|
|
123
|
+
sequence: 0,
|
|
124
|
+
clear: () => {
|
|
125
|
+
buffer.events.splice(0, buffer.events.length);
|
|
126
|
+
buffer.sequence = 0;
|
|
127
|
+
},
|
|
128
|
+
dump: () => [...buffer.events],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
window.__UDS_CCTV_DEBUG__ = buffer;
|
|
132
|
+
return buffer;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* CCTV runtime 진단 이벤트를 ring buffer와 console에 남긴다.
|
|
137
|
+
* @desc
|
|
138
|
+
* 토큰 값은 payload에 넣지 않는다. 이 logger는 debug flag가 켜진 경우에만 동작한다.
|
|
139
|
+
*/
|
|
140
|
+
export const logCctvDebugEvent = ({
|
|
141
|
+
event,
|
|
142
|
+
level = "debug",
|
|
143
|
+
payload,
|
|
144
|
+
source,
|
|
145
|
+
}: {
|
|
146
|
+
event: string;
|
|
147
|
+
level?: CctvDebugEventLevel;
|
|
148
|
+
payload?: Record<string, unknown>;
|
|
149
|
+
source: string;
|
|
150
|
+
}): void => {
|
|
151
|
+
const buffer = getCctvDebugBuffer();
|
|
152
|
+
if (!buffer) return;
|
|
153
|
+
|
|
154
|
+
const entry: CctvDebugEvent = {
|
|
155
|
+
at: new Date().toISOString(),
|
|
156
|
+
event,
|
|
157
|
+
level,
|
|
158
|
+
payload,
|
|
159
|
+
seq: buffer.sequence + 1,
|
|
160
|
+
source,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
buffer.sequence = entry.seq;
|
|
164
|
+
buffer.events.push(entry);
|
|
165
|
+
if (buffer.events.length > buffer.limit) {
|
|
166
|
+
buffer.events.splice(0, buffer.events.length - buffer.limit);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const logger =
|
|
170
|
+
level === "error"
|
|
171
|
+
? console.error
|
|
172
|
+
: level === "warn"
|
|
173
|
+
? console.warn
|
|
174
|
+
: level === "info"
|
|
175
|
+
? console.info
|
|
176
|
+
: console.debug;
|
|
177
|
+
|
|
178
|
+
logger.call(console, "[UDS:CCTV]", entry);
|
|
179
|
+
};
|
|
@@ -62,6 +62,10 @@ export function getOverlayMessage({
|
|
|
62
62
|
if (canReconnect && RTC_SESSION_ENDED_RECONNECT_STATES.has(connectionState)) {
|
|
63
63
|
return CCTV_MESSAGE.sessionEnded;
|
|
64
64
|
}
|
|
65
|
+
// grace/stagger가 끝난 disconnected는 더 이상 준비 상태가 아니라 재연결 대상 장애다.
|
|
66
|
+
if (canReconnect && connectionState === "disconnected") {
|
|
67
|
+
return CCTV_MESSAGE.offline;
|
|
68
|
+
}
|
|
65
69
|
if (isTokenLoading || isStreaming) return CCTV_MESSAGE.preparing;
|
|
66
70
|
if (RTC_PREPARING_STATES.has(connectionState)) return CCTV_MESSAGE.preparing;
|
|
67
71
|
return null;
|