Skip to content

Commit 99d0926

Browse files
authored
Merge pull request #188 from Sysone-Final/dev
Dev
2 parents 618bb83 + 85095d1 commit 99d0926

6 files changed

Lines changed: 142 additions & 105 deletions

File tree

src/api/sseClient.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,28 @@ export const createSSEConnection = <T = unknown>(
9595
buffer = lines.pop() || "";
9696

9797
for (const line of lines) {
98-
if (line.startsWith("data:")) {
99-
try {
100-
const data = JSON.parse(line.substring(5).trim());
101-
onMessage(data);
102-
} catch (error) {
103-
console.error("Failed to parse SSE data:", error);
98+
if (!line.startsWith("data:")) {
99+
continue;
100+
}
101+
102+
const payload = line.substring(5).trim();
103+
if (!payload) {
104+
continue;
105+
}
106+
107+
// 일부 서버는 연결 확인을 위해 단순 문자열을 보낼 수 있으므로 JSON 형태만 처리
108+
if (!payload.startsWith("{") && !payload.startsWith("[")) {
109+
if (import.meta.env.DEV) {
110+
console.debug("Ignoring non-JSON SSE payload:", payload);
104111
}
112+
continue;
113+
}
114+
115+
try {
116+
const data = JSON.parse(payload);
117+
onMessage(data);
118+
} catch (error) {
119+
console.error("Failed to parse SSE data:", error, payload);
105120
}
106121
}
107122
}

src/main.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import router from "./router";
66
import { Toaster } from "react-hot-toast";
77
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
88
import { useAuthStore } from "@/domains/login/store/useAuthStore";
9+
import AlertProvider from "@/shared/providers/AlertProvider";
910

1011
const queryClient = new QueryClient();
1112

@@ -27,13 +28,15 @@ useAuthStore.getState().restoreAuth();
2728
createRoot(document.getElementById("root")!).render(
2829
<StrictMode>
2930
<QueryClientProvider client={queryClient}>
30-
<RouterProvider router={router} />
31-
<Toaster
32-
position="top-right"
33-
toastOptions={{
34-
duration: 3000,
35-
}}
36-
/>
31+
<AlertProvider>
32+
<RouterProvider router={router} />
33+
<Toaster
34+
position="top-right"
35+
toastOptions={{
36+
duration: 3000,
37+
}}
38+
/>
39+
</AlertProvider>
3740
</QueryClientProvider>
3841
</StrictMode>
3942
);
Lines changed: 38 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
import { useEffect, useState, useRef, useCallback } from 'react';
2-
import { createAlertSSE } from '@/api/sseClient';
3-
import { alertApi, type Alert } from '@/api/alertApi';
2+
import type { Alert } from '@/api/alertApi';
3+
import { useAlertStore } from '@/shared/store/useAlertStore';
44

55
export interface RackAlert {
66
rackId: number;
77
level: 'CRITICAL' | 'WARNING';
88
latestAlert: Alert;
99
}
1010

11+
const ALERT_TTL_MS = 5 * 60 * 1000;
12+
1113
/**
1214
* 서버실 알림을 실시간으로 수신하고 랙별 알림 상태를 관리하는 Hook
1315
* @param serverRoomId 서버실 ID (없으면 전체 알림 수신)
1416
*/
1517
export const useServerRoomAlerts = (serverRoomId?: number) => {
1618
const [rackAlerts, setRackAlerts] = useState<Map<number, RackAlert>>(new Map());
17-
const sseConnectionRef = useRef<ReturnType<typeof createAlertSSE> | null>(null);
18-
const clearTimersRef = useRef<number[]>([]);
19+
const clearTimersRef = useRef<Map<number, number>>(new Map());
20+
const processedAlertsRef = useRef<Set<number>>(new Set());
21+
const alerts = useAlertStore((state) => state.alerts);
1922

2023
const shouldProcessAlert = useCallback(
2124
(alert: Alert) => {
@@ -44,6 +47,9 @@ export const useServerRoomAlerts = (serverRoomId?: number) => {
4447
}, []);
4548

4649
const scheduleAlertCleanup = useCallback((alert: Alert) => {
50+
const elapsed = Date.now() - new Date(alert.triggeredAt).getTime();
51+
const remaining = Math.max(0, ALERT_TTL_MS - elapsed);
52+
4753
const timeoutId = window.setTimeout(() => {
4854
setRackAlerts((prev) => {
4955
const newMap = new Map(prev);
@@ -55,9 +61,15 @@ export const useServerRoomAlerts = (serverRoomId?: number) => {
5561
}
5662
return newMap;
5763
});
58-
}, 300000);
64+
clearTimersRef.current.delete(alert.alertId);
65+
processedAlertsRef.current.delete(alert.alertId);
66+
}, remaining);
5967

60-
clearTimersRef.current.push(timeoutId);
68+
const previousTimer = clearTimersRef.current.get(alert.alertId);
69+
if (previousTimer) {
70+
clearTimeout(previousTimer);
71+
}
72+
clearTimersRef.current.set(alert.alertId, timeoutId);
6173
}, []);
6274

6375
const processAlert = useCallback(
@@ -75,59 +87,35 @@ export const useServerRoomAlerts = (serverRoomId?: number) => {
7587
);
7688

7789
useEffect(() => {
78-
let isMounted = true;
79-
80-
const fetchInitialAlerts = async () => {
81-
try {
82-
const response = await alertApi.getAlerts({ page: 0, size: 200, days: 1 });
83-
if (!isMounted) return;
84-
85-
const relevantAlerts = response.content.filter(shouldProcessAlert);
86-
87-
setRackAlerts((prev) => {
88-
let newMap = new Map(prev);
89-
relevantAlerts.forEach((alert) => {
90-
newMap = upsertAlert(newMap, alert);
91-
scheduleAlertCleanup(alert);
92-
});
93-
return newMap;
94-
});
95-
} catch (error) {
96-
console.error('Failed to fetch initial alerts:', error);
97-
}
98-
};
99-
100-
fetchInitialAlerts();
90+
clearTimersRef.current.forEach((timeoutId) => {
91+
clearTimeout(timeoutId);
92+
});
93+
clearTimersRef.current.clear();
94+
processedAlertsRef.current.clear();
95+
setRackAlerts(new Map());
96+
}, [serverRoomId]);
10197

102-
// SSE 연결 생성
103-
const connection = createAlertSSE<Alert>({
104-
onMessage: (alert) => {
98+
useEffect(() => {
99+
alerts.forEach((alert) => {
100+
if (!processedAlertsRef.current.has(alert.alertId) && shouldProcessAlert(alert)) {
101+
processedAlertsRef.current.add(alert.alertId);
105102
processAlert(alert);
106-
},
107-
onError: (error) => {
108-
console.error('Alert SSE error in useServerRoomAlerts:', error);
109-
},
110-
onOpen: () => {
111-
console.log('Alert SSE connection established in useServerRoomAlerts');
112-
},
103+
}
113104
});
105+
}, [alerts, processAlert, serverRoomId, shouldProcessAlert]);
114106

115-
sseConnectionRef.current = connection;
107+
useEffect(() => {
108+
const timers = clearTimersRef.current;
109+
const processed = processedAlertsRef.current;
116110

117-
// 클린업
118111
return () => {
119-
isMounted = false;
120-
if (sseConnectionRef.current) {
121-
sseConnectionRef.current.close();
122-
sseConnectionRef.current = null;
123-
}
124-
125-
clearTimersRef.current.forEach((timeoutId) => {
112+
timers.forEach((timeoutId) => {
126113
clearTimeout(timeoutId);
127114
});
128-
clearTimersRef.current = [];
115+
timers.clear();
116+
processed.clear();
129117
};
130-
}, [processAlert, scheduleAlertCleanup, shouldProcessAlert, upsertAlert]);
118+
}, []);
131119

132120
return { rackAlerts };
133121
};

src/shared/layout/NotificationBell.tsx

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,19 @@
1-
import { useState, useRef, useEffect } from "react";
1+
import { useState, useRef, useEffect, useMemo } from "react";
22
import { useNavigate } from "react-router-dom";
33
import { IoNotificationsOutline } from "react-icons/io5";
44
import { alertApi, type Alert } from "@/api/alertApi";
5-
import { createAlertSSE } from "@/api/sseClient";
5+
import { useAlertStore } from "@/shared/store/useAlertStore";
66

77
function NotificationBell() {
88
const [isOpen, setIsOpen] = useState(false);
9-
const [alerts, setAlerts] = useState<Alert[]>([]);
109
const dropdownRef = useRef<HTMLDivElement>(null);
1110
const navigate = useNavigate();
11+
const alerts = useAlertStore((state) => state.alerts);
12+
const clearAlerts = useAlertStore((state) => state.clearAlerts);
1213

13-
const unreadCount = alerts.filter((alert) => !alert.isRead).length;
14+
const latestAlerts = useMemo(() => alerts.slice(0, 50), [alerts]);
1415

15-
// SSE 연결 및 초기 데이터 로드
16-
useEffect(() => {
17-
// 초기 알림 데이터 가져오기
18-
const fetchInitialAlerts = async () => {
19-
try {
20-
const response = await alertApi.getAlerts({ page: 0, size: 20, days: 7 });
21-
setAlerts(response.content);
22-
} catch (error) {
23-
console.error("Failed to fetch initial alerts:", error);
24-
}
25-
};
26-
27-
fetchInitialAlerts();
28-
29-
// SSE 연결 생성
30-
const sseConnection = createAlertSSE<Alert>({
31-
onMessage: (newAlert) => {
32-
console.log("New alert received:", newAlert);
33-
// 새 알림을 맨 앞에 추가
34-
setAlerts((prev) => [newAlert, ...prev]);
35-
},
36-
onError: (error) => {
37-
console.error("Alert SSE error:", error);
38-
},
39-
onOpen: () => {
40-
console.log("Alert SSE connection established");
41-
},
42-
});
43-
44-
// 컴포넌트 언마운트 시 SSE 연결 종료
45-
return () => {
46-
sseConnection.close();
47-
};
48-
}, []);
16+
const unreadCount = latestAlerts.filter((alert) => !alert.isRead).length;
4917

5018
// 외부 클릭 감지
5119
useEffect(() => {
@@ -74,7 +42,7 @@ function NotificationBell() {
7442
const handleDeleteAll = async () => {
7543
try {
7644
await alertApi.deleteAllAlerts();
77-
setAlerts([]);
45+
clearAlerts();
7846
} catch (error) {
7947
console.error("Failed to delete all alerts:", error);
8048
}
@@ -137,13 +105,13 @@ function NotificationBell() {
137105
<div className="absolute right-0 mt-2 w-80 bg-black/40 backdrop-blur-sm rounded-lg shadow-xl border border-slate-300/40 z-1000 overflow-hidden">
138106

139107
<div className="max-h-96 overflow-y-auto">
140-
{alerts.length === 0 ? (
108+
{latestAlerts.length === 0 ? (
141109
<div className="px-4 py-8 text-center text-gray-400">
142110
새로운 알림이 없습니다.
143111
</div>
144112
) : (
145113
<div className="divide-y divide-gray-700">
146-
{alerts.map((alert) => (
114+
{latestAlerts.map((alert) => (
147115
<div
148116
key={alert.alertId}
149117
onClick={() => handleAlertClick(alert)}
@@ -180,7 +148,7 @@ function NotificationBell() {
180148
)}
181149
</div>
182150

183-
{alerts.length > 0 && (
151+
{latestAlerts.length > 0 && (
184152
<div className="px-4 py-2 border-t border-gray-700">
185153
<button
186154
onClick={handleDeleteAll}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { type PropsWithChildren, useEffect, useRef } from 'react';
2+
import { createAlertSSE } from '@/api/sseClient';
3+
import type { Alert } from '@/api/alertApi';
4+
import { useAlertStore } from '@/shared/store/useAlertStore';
5+
6+
const AlertProvider = ({ children }: PropsWithChildren): JSX.Element => {
7+
const addAlert = useAlertStore((state) => state.addAlert);
8+
const connectionRef = useRef<ReturnType<typeof createAlertSSE> | null>(null);
9+
const addAlertRef = useRef(addAlert);
10+
11+
useEffect(() => {
12+
addAlertRef.current = addAlert;
13+
}, [addAlert]);
14+
15+
useEffect(() => {
16+
const connection = createAlertSSE<Alert>({
17+
onMessage: (alert) => {
18+
addAlertRef.current(alert);
19+
},
20+
onError: (error) => {
21+
console.error('Global Alert SSE error:', error);
22+
},
23+
onOpen: () => {
24+
console.log('Global Alert SSE connection established');
25+
},
26+
});
27+
28+
connectionRef.current = connection;
29+
30+
return () => {
31+
connection.close();
32+
connectionRef.current = null;
33+
};
34+
}, []);
35+
36+
return <>{children}</>;
37+
};
38+
39+
export default AlertProvider;

src/shared/store/useAlertStore.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { create } from 'zustand';
2+
import type { Alert } from '@/api/alertApi';
3+
4+
interface AlertStoreState {
5+
alerts: Alert[];
6+
maxAlerts: number;
7+
addAlert: (alert: Alert) => void;
8+
clearAlerts: () => void;
9+
}
10+
11+
export const useAlertStore = create<AlertStoreState>((set) => ({
12+
alerts: [],
13+
maxAlerts: 200,
14+
addAlert: (alert) =>
15+
set((state) => {
16+
const deduped = state.alerts.filter((existing) => existing.alertId !== alert.alertId);
17+
const next = [alert, ...deduped];
18+
return {
19+
alerts: next.slice(0, state.maxAlerts),
20+
maxAlerts: state.maxAlerts,
21+
};
22+
}),
23+
clearAlerts: () => set((state) => ({ alerts: [], maxAlerts: state.maxAlerts })),
24+
}));

0 commit comments

Comments
 (0)