Skip to content
Merged

Dev #188

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions src/api/sseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,28 @@ export const createSSEConnection = <T = unknown>(
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);
}
}
}
Expand Down
17 changes: 10 additions & 7 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -27,13 +28,15 @@ useAuthStore.getState().restoreAuth();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
}}
/>
<AlertProvider>
<RouterProvider router={router} />
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
}}
/>
</AlertProvider>
</QueryClientProvider>
</StrictMode>
);
Expand Down
88 changes: 38 additions & 50 deletions src/shared/hooks/useServerRoomAlerts.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
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;
level: 'CRITICAL' | 'WARNING';
latestAlert: Alert;
}

const ALERT_TTL_MS = 5 * 60 * 1000;

/**
* 서버실 알림을 실시간으로 수신하고 랙별 알림 상태를 관리하는 Hook
* @param serverRoomId 서버실 ID (없으면 전체 알림 수신)
*/
export const useServerRoomAlerts = (serverRoomId?: number) => {
const [rackAlerts, setRackAlerts] = useState<Map<number, RackAlert>>(new Map());
const sseConnectionRef = useRef<ReturnType<typeof createAlertSSE> | null>(null);
const clearTimersRef = useRef<number[]>([]);
const clearTimersRef = useRef<Map<number, number>>(new Map());
const processedAlertsRef = useRef<Set<number>>(new Set());
const alerts = useAlertStore((state) => state.alerts);

const shouldProcessAlert = useCallback(
(alert: Alert) => {
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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<Alert>({
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 };
};
52 changes: 10 additions & 42 deletions src/shared/layout/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -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<Alert[]>([]);
const dropdownRef = useRef<HTMLDivElement>(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<Alert>({
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(() => {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -137,13 +105,13 @@ function NotificationBell() {
<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">

<div className="max-h-96 overflow-y-auto">
{alerts.length === 0 ? (
{latestAlerts.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-400">
새로운 알림이 없습니다.
</div>
) : (
<div className="divide-y divide-gray-700">
{alerts.map((alert) => (
{latestAlerts.map((alert) => (
<div
key={alert.alertId}
onClick={() => handleAlertClick(alert)}
Expand Down Expand Up @@ -180,7 +148,7 @@ function NotificationBell() {
)}
</div>

{alerts.length > 0 && (
{latestAlerts.length > 0 && (
<div className="px-4 py-2 border-t border-gray-700">
<button
onClick={handleDeleteAll}
Expand Down
39 changes: 39 additions & 0 deletions src/shared/providers/AlertProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type PropsWithChildren, useEffect, useRef } from 'react';
import { createAlertSSE } from '@/api/sseClient';
import type { Alert } from '@/api/alertApi';
import { useAlertStore } from '@/shared/store/useAlertStore';

const AlertProvider = ({ children }: PropsWithChildren): JSX.Element => {
const addAlert = useAlertStore((state) => state.addAlert);
const connectionRef = useRef<ReturnType<typeof createAlertSSE> | null>(null);
const addAlertRef = useRef(addAlert);

useEffect(() => {
addAlertRef.current = addAlert;
}, [addAlert]);

useEffect(() => {
const connection = createAlertSSE<Alert>({
onMessage: (alert) => {
addAlertRef.current(alert);
},
onError: (error) => {
console.error('Global Alert SSE error:', error);
},
onOpen: () => {
console.log('Global Alert SSE connection established');
},
});

connectionRef.current = connection;

return () => {
connection.close();
connectionRef.current = null;
};
}, []);

return <>{children}</>;
};

export default AlertProvider;
24 changes: 24 additions & 0 deletions src/shared/store/useAlertStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { create } from 'zustand';
import type { Alert } from '@/api/alertApi';

interface AlertStoreState {
alerts: Alert[];
maxAlerts: number;
addAlert: (alert: Alert) => void;
clearAlerts: () => void;
}

export const useAlertStore = create<AlertStoreState>((set) => ({
alerts: [],
maxAlerts: 200,
addAlert: (alert) =>
set((state) => {
const deduped = state.alerts.filter((existing) => existing.alertId !== alert.alertId);
const next = [alert, ...deduped];
return {
alerts: next.slice(0, state.maxAlerts),
maxAlerts: state.maxAlerts,
};
}),
clearAlerts: () => set((state) => ({ alerts: [], maxAlerts: state.maxAlerts })),
}));
Loading