diff --git a/src/api/sseClient.ts b/src/api/sseClient.ts index 49a339e..b778440 100644 --- a/src/api/sseClient.ts +++ b/src/api/sseClient.ts @@ -95,13 +95,28 @@ export const createSSEConnection = ( buffer = lines.pop() || ""; for (const line of lines) { - if (line.startsWith("data:")) { - try { - const data = JSON.parse(line.substring(5).trim()); - onMessage(data); - } catch (error) { - console.error("Failed to parse SSE data:", error); + if (!line.startsWith("data:")) { + continue; + } + + const payload = line.substring(5).trim(); + if (!payload) { + continue; + } + + // 일부 서버는 연결 확인을 위해 단순 문자열을 보낼 수 있으므로 JSON 형태만 처리 + if (!payload.startsWith("{") && !payload.startsWith("[")) { + if (import.meta.env.DEV) { + console.debug("Ignoring non-JSON SSE payload:", payload); } + continue; + } + + try { + const data = JSON.parse(payload); + onMessage(data); + } catch (error) { + console.error("Failed to parse SSE data:", error, payload); } } } diff --git a/src/main.tsx b/src/main.tsx index 4060255..68e66e7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import router from "./router"; import { Toaster } from "react-hot-toast"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useAuthStore } from "@/domains/login/store/useAuthStore"; +import AlertProvider from "@/shared/providers/AlertProvider"; const queryClient = new QueryClient(); @@ -27,13 +28,15 @@ useAuthStore.getState().restoreAuth(); createRoot(document.getElementById("root")!).render( - - + + + + ); diff --git a/src/shared/hooks/useServerRoomAlerts.ts b/src/shared/hooks/useServerRoomAlerts.ts index 9c4401a..b8463ff 100644 --- a/src/shared/hooks/useServerRoomAlerts.ts +++ b/src/shared/hooks/useServerRoomAlerts.ts @@ -1,6 +1,6 @@ import { useEffect, useState, useRef, useCallback } from 'react'; -import { createAlertSSE } from '@/api/sseClient'; -import { alertApi, type Alert } from '@/api/alertApi'; +import type { Alert } from '@/api/alertApi'; +import { useAlertStore } from '@/shared/store/useAlertStore'; export interface RackAlert { rackId: number; @@ -8,14 +8,17 @@ export interface RackAlert { latestAlert: Alert; } +const ALERT_TTL_MS = 5 * 60 * 1000; + /** * 서버실 알림을 실시간으로 수신하고 랙별 알림 상태를 관리하는 Hook * @param serverRoomId 서버실 ID (없으면 전체 알림 수신) */ export const useServerRoomAlerts = (serverRoomId?: number) => { const [rackAlerts, setRackAlerts] = useState>(new Map()); - const sseConnectionRef = useRef | null>(null); - const clearTimersRef = useRef([]); + const clearTimersRef = useRef>(new Map()); + const processedAlertsRef = useRef>(new Set()); + const alerts = useAlertStore((state) => state.alerts); const shouldProcessAlert = useCallback( (alert: Alert) => { @@ -44,6 +47,9 @@ export const useServerRoomAlerts = (serverRoomId?: number) => { }, []); const scheduleAlertCleanup = useCallback((alert: Alert) => { + const elapsed = Date.now() - new Date(alert.triggeredAt).getTime(); + const remaining = Math.max(0, ALERT_TTL_MS - elapsed); + const timeoutId = window.setTimeout(() => { setRackAlerts((prev) => { const newMap = new Map(prev); @@ -55,9 +61,15 @@ export const useServerRoomAlerts = (serverRoomId?: number) => { } return newMap; }); - }, 300000); + clearTimersRef.current.delete(alert.alertId); + processedAlertsRef.current.delete(alert.alertId); + }, remaining); - clearTimersRef.current.push(timeoutId); + const previousTimer = clearTimersRef.current.get(alert.alertId); + if (previousTimer) { + clearTimeout(previousTimer); + } + clearTimersRef.current.set(alert.alertId, timeoutId); }, []); const processAlert = useCallback( @@ -75,59 +87,35 @@ export const useServerRoomAlerts = (serverRoomId?: number) => { ); useEffect(() => { - let isMounted = true; - - const fetchInitialAlerts = async () => { - try { - const response = await alertApi.getAlerts({ page: 0, size: 200, days: 1 }); - if (!isMounted) return; - - const relevantAlerts = response.content.filter(shouldProcessAlert); - - setRackAlerts((prev) => { - let newMap = new Map(prev); - relevantAlerts.forEach((alert) => { - newMap = upsertAlert(newMap, alert); - scheduleAlertCleanup(alert); - }); - return newMap; - }); - } catch (error) { - console.error('Failed to fetch initial alerts:', error); - } - }; - - fetchInitialAlerts(); + clearTimersRef.current.forEach((timeoutId) => { + clearTimeout(timeoutId); + }); + clearTimersRef.current.clear(); + processedAlertsRef.current.clear(); + setRackAlerts(new Map()); + }, [serverRoomId]); - // SSE 연결 생성 - const connection = createAlertSSE({ - onMessage: (alert) => { + useEffect(() => { + alerts.forEach((alert) => { + if (!processedAlertsRef.current.has(alert.alertId) && shouldProcessAlert(alert)) { + processedAlertsRef.current.add(alert.alertId); processAlert(alert); - }, - onError: (error) => { - console.error('Alert SSE error in useServerRoomAlerts:', error); - }, - onOpen: () => { - console.log('Alert SSE connection established in useServerRoomAlerts'); - }, + } }); + }, [alerts, processAlert, serverRoomId, shouldProcessAlert]); - sseConnectionRef.current = connection; + useEffect(() => { + const timers = clearTimersRef.current; + const processed = processedAlertsRef.current; - // 클린업 return () => { - isMounted = false; - if (sseConnectionRef.current) { - sseConnectionRef.current.close(); - sseConnectionRef.current = null; - } - - clearTimersRef.current.forEach((timeoutId) => { + timers.forEach((timeoutId) => { clearTimeout(timeoutId); }); - clearTimersRef.current = []; + timers.clear(); + processed.clear(); }; - }, [processAlert, scheduleAlertCleanup, shouldProcessAlert, upsertAlert]); + }, []); return { rackAlerts }; }; diff --git a/src/shared/layout/NotificationBell.tsx b/src/shared/layout/NotificationBell.tsx index 24744ba..a4bb4f1 100644 --- a/src/shared/layout/NotificationBell.tsx +++ b/src/shared/layout/NotificationBell.tsx @@ -1,51 +1,19 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { IoNotificationsOutline } from "react-icons/io5"; import { alertApi, type Alert } from "@/api/alertApi"; -import { createAlertSSE } from "@/api/sseClient"; +import { useAlertStore } from "@/shared/store/useAlertStore"; function NotificationBell() { const [isOpen, setIsOpen] = useState(false); - const [alerts, setAlerts] = useState([]); const dropdownRef = useRef(null); const navigate = useNavigate(); + const alerts = useAlertStore((state) => state.alerts); + const clearAlerts = useAlertStore((state) => state.clearAlerts); - const unreadCount = alerts.filter((alert) => !alert.isRead).length; + const latestAlerts = useMemo(() => alerts.slice(0, 50), [alerts]); - // SSE 연결 및 초기 데이터 로드 - useEffect(() => { - // 초기 알림 데이터 가져오기 - const fetchInitialAlerts = async () => { - try { - const response = await alertApi.getAlerts({ page: 0, size: 20, days: 7 }); - setAlerts(response.content); - } catch (error) { - console.error("Failed to fetch initial alerts:", error); - } - }; - - fetchInitialAlerts(); - - // SSE 연결 생성 - const sseConnection = createAlertSSE({ - onMessage: (newAlert) => { - console.log("New alert received:", newAlert); - // 새 알림을 맨 앞에 추가 - setAlerts((prev) => [newAlert, ...prev]); - }, - onError: (error) => { - console.error("Alert SSE error:", error); - }, - onOpen: () => { - console.log("Alert SSE connection established"); - }, - }); - - // 컴포넌트 언마운트 시 SSE 연결 종료 - return () => { - sseConnection.close(); - }; - }, []); + const unreadCount = latestAlerts.filter((alert) => !alert.isRead).length; // 외부 클릭 감지 useEffect(() => { @@ -74,7 +42,7 @@ function NotificationBell() { const handleDeleteAll = async () => { try { await alertApi.deleteAllAlerts(); - setAlerts([]); + clearAlerts(); } catch (error) { console.error("Failed to delete all alerts:", error); } @@ -137,13 +105,13 @@ function NotificationBell() {
- {alerts.length === 0 ? ( + {latestAlerts.length === 0 ? (
새로운 알림이 없습니다.
) : (
- {alerts.map((alert) => ( + {latestAlerts.map((alert) => (
handleAlertClick(alert)} @@ -180,7 +148,7 @@ function NotificationBell() { )}
- {alerts.length > 0 && ( + {latestAlerts.length > 0 && (