@uniai-fe/uds-templates 0.6.9 → 0.6.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -32,16 +32,10 @@ const POST_CONNECTED_RECONNECT_GRACE_MS = 5000;
32
32
  const POST_CONNECTED_RECONNECT_STAGGER_MS = 5000;
33
33
  const TOKEN_EXPIRY_SAFETY_MS = 30 * 1000;
34
34
 
35
- type CctvRtcReconnectReason =
36
- | "tokenError"
37
- | "streamError"
38
- | "failed"
39
- | "disconnected";
35
+ type CctvRtcReconnectReason = "tokenError" | "streamError" | "failed";
40
36
 
41
- const DISPLAY_CONNECTED_DURING_GRACE_STATES = new Set<RTCPeerConnectionState>([
42
- "disconnected",
43
- "failed",
44
- ]);
37
+ const DISPLAY_CONNECTED_DURING_RECOVERY_STATES =
38
+ new Set<RTCPeerConnectionState>(["disconnected", "failed"]);
45
39
 
46
40
  /**
47
41
  * 같은 회사/카메라 묶음이 동시에 재연결하지 않도록 identity 기반 지연값을 만든다.
@@ -70,6 +64,8 @@ const getPostConnectedReconnectDelayMs = (delayKey: string): number =>
70
64
  * @desc
71
65
  * `closed`는 `streamRegistry.closeByIdentity()`처럼 UDS가 의도적으로 닫을 때도 발생하므로
72
66
  * 자동 재연결 사유에 포함하지 않는다.
67
+ * `disconnected`는 ICE transient 상태에서도 발생하고 기존 MediaStream track이 살아 있을 수 있으므로,
68
+ * media-health 확인 없이 자동 재연결을 유발하지 않는다.
73
69
  */
74
70
  const getPostConnectedReconnectReason = ({
75
71
  connectionState,
@@ -83,7 +79,6 @@ const getPostConnectedReconnectReason = ({
83
79
  if (isTokenError) return "tokenError";
84
80
  if (streamError) return "streamError";
85
81
  if (connectionState === "failed") return "failed";
86
- if (connectionState === "disconnected") return "disconnected";
87
82
  return null;
88
83
  };
89
84
 
@@ -336,12 +331,44 @@ export function useCctvRtcStream({
336
331
  const shouldPreserveConnectedDisplay =
337
332
  hasConnected &&
338
333
  !isPostConnectedReconnectReady &&
339
- DISPLAY_CONNECTED_DURING_GRACE_STATES.has(connectionState);
334
+ DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState);
340
335
 
336
+ // 반환 state는 CamList/Viewer/overlay의 live/error/message 계산에 직접 쓰인다.
337
+ // post-connected disconnected는 자동 재연결 대신 기존 화면을 유지해 UI reset 파동을 막는다.
341
338
  const displayConnectionState = shouldPreserveConnectedDisplay
342
339
  ? "connected"
343
340
  : connectionState;
344
341
 
342
+ const skippedDisconnectedDebugKeyRef = useRef<string | null>(null);
343
+
344
+ useEffect(() => {
345
+ if (
346
+ connectionState !== "disconnected" ||
347
+ !hasConnected ||
348
+ !streamIdentityKey
349
+ ) {
350
+ if (connectionState !== "disconnected") {
351
+ skippedDisconnectedDebugKeyRef.current = null;
352
+ }
353
+ return;
354
+ }
355
+
356
+ const skipKey = `${streamIdentityKey}|${activeStreamKeyRef.current ?? ""}`;
357
+ if (skippedDisconnectedDebugKeyRef.current === skipKey) return;
358
+
359
+ skippedDisconnectedDebugKeyRef.current = skipKey;
360
+ logCctvDebugEvent({
361
+ event: "reconnect-gate:skip-disconnected",
362
+ payload: {
363
+ ...debugBasePayload,
364
+ connectionState,
365
+ reason: "disconnected-observed-only",
366
+ streamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
367
+ },
368
+ source: "useRtcStream",
369
+ });
370
+ }, [connectionState, debugBasePayload, hasConnected, streamIdentityKey]);
371
+
345
372
  const hasReusableRegistryStream = useMemo(() => {
346
373
  if (!streamKeyCandidate) return false;
347
374
 
@@ -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 UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
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,10 +98,12 @@ export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
98
98
  */
99
99
  videoRef: React.RefObject<HTMLVideoElement | null>;
100
100
  /**
101
- * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
101
+ * UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
102
102
  * @desc
103
103
  * 스트림 장애 감지 즉시 true가 되는 플래그가 아니라,
104
104
  * 서비스 overlay가 reconnectStream()을 호출해도 되는 시점에 true가 된다.
105
+ * post-connected `disconnected`는 자동 재연결 사유가 아니며,
106
+ * token/stream 오류 또는 `failed`처럼 명확한 복구 사유만 true 전환 후보가 된다.
105
107
  */
106
108
  canReconnect: boolean;
107
109
  /**
@@ -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] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
12
+ * @property {boolean | undefined} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
13
13
  * @property {CctvRtcReconnectTrigger | undefined} [reconnectStream] 재연결 trigger 함수
14
14
  */
15
15
  export interface CctvVideoRenderOverlayContext {
@@ -38,10 +38,11 @@ export interface CctvVideoRenderOverlayContext {
38
38
  */
39
39
  overlayMessage?: React.ReactNode;
40
40
  /**
41
- * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
41
+ * UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
42
42
  * @desc
43
43
  * custom overlay에서 자동 재연결을 구현할 때 이 값이 true인 경우에만
44
44
  * reconnectStream()을 호출해야 한다.
45
+ * post-connected `disconnected`는 이 값만으로 자동 재연결하지 않는다.
45
46
  */
46
47
  canReconnect?: boolean;
47
48
  /**
@@ -129,7 +130,7 @@ export interface CctvVideoOverlayFooterProps {
129
130
  * @property {boolean} [isError] 에러 상태 여부
130
131
  * @property {boolean} [isLive] 영상이 정상적으로 재생 중인지 여부
131
132
  * @property {React.ReactNode} [overlayMessage] 에러/안내 메시지 콘텐츠
132
- * @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
133
+ * @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
133
134
  * @property {CctvRtcReconnectTrigger} [reconnectStream] 재연결 trigger 함수
134
135
  */
135
136
  export interface CctvVideoStateProps {
@@ -146,7 +147,7 @@ export interface CctvVideoStateProps {
146
147
  */
147
148
  overlayMessage?: React.ReactNode;
148
149
  /**
149
- * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
150
+ * UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
150
151
  */
151
152
  canReconnect?: boolean;
152
153
  /**
@@ -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] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
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
- * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
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] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
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
- * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
64
+ * UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
65
65
  */
66
66
  canReconnect?: boolean;
67
67
  }
@@ -11,13 +11,36 @@ export interface CctvDebugEvent {
11
11
  source: string;
12
12
  }
13
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
+
14
35
  export interface CctvDebugBuffer {
15
36
  clear: () => void;
16
37
  dump: () => CctvDebugEvent[];
17
38
  enabled: true;
18
39
  events: CctvDebugEvent[];
40
+ filter: (keywords?: readonly string[]) => CctvDebugEvent[];
19
41
  limit: number;
20
42
  sequence: number;
43
+ summary: () => CctvDebugSummary;
21
44
  }
22
45
 
23
46
  declare global {
@@ -28,15 +51,175 @@ declare global {
28
51
 
29
52
  const DEBUG_QUERY_KEYS = ["udsCctvDebug", "cctvDebug"] as const;
30
53
  const DEBUG_STORAGE_KEYS = ["uds:cctv:debug", "UDS_CCTV_DEBUG"] as const;
54
+ const DEBUG_CONSOLE_QUERY_KEYS = [
55
+ "udsCctvDebugConsole",
56
+ "cctvDebugConsole",
57
+ ] as const;
58
+ const DEBUG_CONSOLE_STORAGE_KEYS = [
59
+ "uds:cctv:debug:console",
60
+ "UDS_CCTV_DEBUG_CONSOLE",
61
+ ] as const;
31
62
  const DEBUG_ENABLED_VALUES = new Set(["1", "true", "yes", "on", "debug"]);
63
+ const DEBUG_DISABLED_VALUES = new Set(["0", "false", "no", "off"]);
32
64
  const DEBUG_BUFFER_LIMIT = 1000;
65
+ const DEBUG_DEFAULT_FILTER_KEYWORDS = [
66
+ "reconnect",
67
+ "close",
68
+ "rejected",
69
+ "connection-state:change",
70
+ "track:received",
71
+ ] as const;
33
72
 
34
73
  const isBrowser = (): boolean =>
35
74
  typeof window !== "undefined" && typeof document !== "undefined";
36
75
 
37
- const isDebugValueEnabled = (value: string | null): boolean =>
38
- value === "" ||
39
- (typeof value === "string" && DEBUG_ENABLED_VALUES.has(value));
76
+ const getDebugValueOverride = (value: string | null): boolean | null => {
77
+ if (value === "") return true;
78
+ if (typeof value !== "string") return null;
79
+
80
+ const normalizedValue = value.toLowerCase();
81
+ if (DEBUG_ENABLED_VALUES.has(normalizedValue)) return true;
82
+ if (DEBUG_DISABLED_VALUES.has(normalizedValue)) return false;
83
+ return null;
84
+ };
85
+
86
+ const getDebugQueryOverride = (keys: readonly string[]): boolean | null => {
87
+ const query = new URLSearchParams(window.location.search);
88
+ for (const key of keys) {
89
+ if (!query.has(key)) continue;
90
+
91
+ const override = getDebugValueOverride(query.get(key));
92
+ if (override !== null) return override;
93
+ }
94
+
95
+ return null;
96
+ };
97
+
98
+ const getDebugStorageOverride = (keys: readonly string[]): boolean | null => {
99
+ for (const key of keys) {
100
+ try {
101
+ const override = getDebugValueOverride(window.localStorage.getItem(key));
102
+ if (override !== null) return override;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ return null;
109
+ };
110
+
111
+ const isLocalhostDebugDefaultEnabled = (): boolean => {
112
+ const { hostname } = window.location;
113
+ return (
114
+ hostname === "localhost" ||
115
+ hostname === "127.0.0.1" ||
116
+ hostname === "[::1]" ||
117
+ hostname === "::1" ||
118
+ hostname.endsWith(".localhost")
119
+ );
120
+ };
121
+
122
+ const getPayloadString = (
123
+ payload: Record<string, unknown> | undefined,
124
+ key: string,
125
+ ): string | null => {
126
+ const value = payload?.[key];
127
+ return typeof value === "string" ? value : null;
128
+ };
129
+
130
+ const getPayloadBoolean = (
131
+ payload: Record<string, unknown> | undefined,
132
+ key: string,
133
+ ): boolean | null => {
134
+ const value = payload?.[key];
135
+ return typeof value === "boolean" ? value : null;
136
+ };
137
+
138
+ const getReconnectCaller = (
139
+ payload: Record<string, unknown> | undefined,
140
+ ): string | null => {
141
+ const callerStack = payload?.callerStack;
142
+ const stackLines = Array.isArray(callerStack)
143
+ ? callerStack.filter((line): line is string => typeof line === "string")
144
+ : typeof callerStack === "string"
145
+ ? callerStack.split("\n")
146
+ : [];
147
+
148
+ return (
149
+ stackLines
150
+ .find(
151
+ line =>
152
+ line.includes("CCTVManagerVideoOverlay") ||
153
+ line.includes("reconnectOnFocus"),
154
+ )
155
+ ?.trim() ?? null
156
+ );
157
+ };
158
+
159
+ const countBy = <T extends string>(
160
+ target: Record<T, number>,
161
+ key: T | null,
162
+ ): void => {
163
+ if (!key) return;
164
+ target[key] = (target[key] ?? 0) + 1;
165
+ };
166
+
167
+ const filterCctvDebugEvents = (
168
+ events: readonly CctvDebugEvent[],
169
+ keywords: readonly string[] = DEBUG_DEFAULT_FILTER_KEYWORDS,
170
+ ): CctvDebugEvent[] =>
171
+ events.filter(entry =>
172
+ keywords.some(keyword => entry.event.includes(keyword)),
173
+ );
174
+
175
+ const summarizeCctvDebugEvents = (
176
+ events: readonly CctvDebugEvent[],
177
+ ): CctvDebugSummary => {
178
+ const eventCounts: Record<string, number> = {};
179
+ const connectionStateCounts: Record<string, number> = {};
180
+ const reconnectReasons: Record<string, number> = {};
181
+ const reconnectCallers: Record<string, number> = {};
182
+ const reconnectStarts: CctvDebugReconnectStartSummary[] = [];
183
+
184
+ for (const entry of events) {
185
+ countBy(eventCounts, entry.event);
186
+
187
+ if (entry.event === "connection-state:change") {
188
+ countBy(connectionStateCounts, getPayloadString(entry.payload, "state"));
189
+ }
190
+
191
+ if (entry.event !== "reconnect-stream:start") continue;
192
+
193
+ const reconnectReason = getPayloadString(entry.payload, "reconnectReason");
194
+ const caller = getReconnectCaller(entry.payload);
195
+
196
+ countBy(reconnectReasons, reconnectReason ?? "none");
197
+ countBy(reconnectCallers, caller ?? "unknown");
198
+ reconnectStarts.push({
199
+ at: entry.at,
200
+ caller,
201
+ camId: getPayloadString(entry.payload, "camId"),
202
+ connectionState: getPayloadString(entry.payload, "connectionState"),
203
+ isPostConnectedReconnectReady: getPayloadBoolean(
204
+ entry.payload,
205
+ "isPostConnectedReconnectReady",
206
+ ),
207
+ reconnectReason,
208
+ });
209
+ }
210
+
211
+ return {
212
+ connectionStateCounts,
213
+ eventCounts,
214
+ first: events[0]?.at ?? null,
215
+ generatedAt: new Date().toISOString(),
216
+ last: events[events.length - 1]?.at ?? null,
217
+ reconnectCallers,
218
+ reconnectReasons,
219
+ reconnectStarts,
220
+ total: events.length,
221
+ };
222
+ };
40
223
 
41
224
  /**
42
225
  * identity/stream key를 원문 대신 추적 가능한 hash label로 바꾼다.
@@ -79,20 +262,30 @@ export const getCctvDebugUrlLabel = (
79
262
  export const isCctvDebugEnabled = (): boolean => {
80
263
  if (!isBrowser()) return false;
81
264
 
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
- }
265
+ const queryOverride = getDebugQueryOverride(DEBUG_QUERY_KEYS);
266
+ if (queryOverride !== null) return queryOverride;
86
267
 
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
- }
268
+ const storageOverride = getDebugStorageOverride(DEBUG_STORAGE_KEYS);
269
+ if (storageOverride !== null) return storageOverride;
94
270
 
95
- return false;
271
+ return isLocalhostDebugDefaultEnabled();
272
+ };
273
+
274
+ const isCctvDebugConsoleEnabled = (): boolean => {
275
+ if (!isBrowser()) return false;
276
+
277
+ const consoleQueryOverride = getDebugQueryOverride(DEBUG_CONSOLE_QUERY_KEYS);
278
+ if (consoleQueryOverride !== null) return consoleQueryOverride;
279
+
280
+ const consoleStorageOverride = getDebugStorageOverride(
281
+ DEBUG_CONSOLE_STORAGE_KEYS,
282
+ );
283
+ if (consoleStorageOverride !== null) return consoleStorageOverride;
284
+
285
+ return (
286
+ getDebugQueryOverride(DEBUG_QUERY_KEYS) === true ||
287
+ getDebugStorageOverride(DEBUG_STORAGE_KEYS) === true
288
+ );
96
289
  };
97
290
 
98
291
  /**
@@ -126,6 +319,8 @@ const getCctvDebugBuffer = (): CctvDebugBuffer | null => {
126
319
  buffer.sequence = 0;
127
320
  },
128
321
  dump: () => [...buffer.events],
322
+ filter: keywords => filterCctvDebugEvents(buffer.events, keywords),
323
+ summary: () => summarizeCctvDebugEvents(buffer.events),
129
324
  };
130
325
 
131
326
  window.__UDS_CCTV_DEBUG__ = buffer;
@@ -166,6 +361,8 @@ export const logCctvDebugEvent = ({
166
361
  buffer.events.splice(0, buffer.events.length - buffer.limit);
167
362
  }
168
363
 
364
+ if (!isCctvDebugConsoleEnabled()) return;
365
+
169
366
  const logger =
170
367
  level === "error"
171
368
  ? console.error
@@ -62,10 +62,7 @@ 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
+ // raw disconnected는 useCctvRtcStream에서 post-connected connected로 smoothing한다.
69
66
  if (isTokenLoading || isStreaming) return CCTV_MESSAGE.preparing;
70
67
  if (RTC_PREPARING_STATES.has(connectionState)) return CCTV_MESSAGE.preparing;
71
68
  return null;