From b647dbfb74d2bca864a588367773e9002db403f9 Mon Sep 17 00:00:00 2001 From: KIM_DEAHO <102588838+DHowor1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 22:01:44 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=ED=9E=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC,=20alert,=20=EB=9E=99=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/alertApi.ts | 65 +++ .../components/CpuUsageChart.tsx | 6 +- .../components/DiskUsageChart.tsx | 6 +- .../components/EquipmentTypeChart.tsx | 106 +++++ .../components/RackDashboard.tsx | 410 +++++++++++++----- .../components/TopEquipmentsTable.tsx | 85 ++++ src/domains/mainDashboard/components/index.ts | 2 + .../mainDashboard/pages/MainDashboard.tsx | 8 +- .../mainDashboard/types/dashboard.types.ts | 92 ++++ src/domains/serverView/api/historyApi.ts | 66 +++ .../components/ServerRoomHistoryPanel.tsx | 348 +++++++++++++++ .../serverView/pages/ServerViewPage.tsx | 3 + src/shared/hooks/index.ts | 2 + src/shared/hooks/useRackSSE.ts | 160 +++++++ src/shared/layout/Header.tsx | 4 + src/shared/layout/NotificationBell.tsx | 159 +++++++ 16 files changed, 1392 insertions(+), 130 deletions(-) create mode 100644 src/api/alertApi.ts create mode 100644 src/domains/mainDashboard/components/EquipmentTypeChart.tsx create mode 100644 src/domains/mainDashboard/components/TopEquipmentsTable.tsx create mode 100644 src/domains/serverView/api/historyApi.ts create mode 100644 src/domains/serverView/components/ServerRoomHistoryPanel.tsx create mode 100644 src/shared/hooks/useRackSSE.ts create mode 100644 src/shared/layout/NotificationBell.tsx diff --git a/src/api/alertApi.ts b/src/api/alertApi.ts new file mode 100644 index 0000000..1beb0b2 --- /dev/null +++ b/src/api/alertApi.ts @@ -0,0 +1,65 @@ +import client from "./client"; + +export interface Alert { + alertId: number; + equipmentId: number; + rackId: number; + serverRoomId: number; + dataCenterId: number; + targetName: string; + targetType: string; + metricType: string; + metricName: string; + level: string; + measuredValue: number; + thresholdValue: number; + triggeredAt: string; + isRead: boolean; + readAt: string | null; + readBy: string | null; + message: string; + additionalInfo: string | null; + createdAt: string; +} + +export interface AlertsResponse { + totalPages: number; + pageSize: number; + hasPrevious: boolean; + hasNext: boolean; + currentPage: number; + content: Alert[]; + totalElements: number; +} + +export interface AlertsParams { + page?: number; + size?: number; + days?: number; +} + +export const alertApi = { + // 알림 목록 조회 + getAlerts: async (params: AlertsParams = {}): Promise => { + const { page = 0, size = 10, days = 7 } = params; + const response = await client.get("/alerts", { + params: { page, size, days }, + }); + return response.data; + }, + + // 알림 읽음 처리 (필요시 구현) + markAsRead: async (alertId: number): Promise => { + await client.patch(`/alerts/${alertId}/read`); + }, + + // 모든 알림 읽음 처리 (필요시 구현) + markAllAsRead: async (): Promise => { + await client.patch("/alerts/read-all"); + }, + + // 알림 삭제 (필요시 구현) + deleteAlert: async (alertId: number): Promise => { + await client.delete(`/alerts/${alertId}`); + }, +}; diff --git a/src/domains/mainDashboard/components/CpuUsageChart.tsx b/src/domains/mainDashboard/components/CpuUsageChart.tsx index 92c693f..9cb7b18 100644 --- a/src/domains/mainDashboard/components/CpuUsageChart.tsx +++ b/src/domains/mainDashboard/components/CpuUsageChart.tsx @@ -33,19 +33,19 @@ export default function CpuUsageChart({ const series: LineChartSeries[] = [ { name: "최대 사용량", - data: data.map((item) => item.maxUsage.toFixed(2)), + data: data.map((item) => (item.maxUsage ?? 0).toFixed(2)), showArea: true, color: "#eab308", // 노란색 }, { name: "평균 사용량", - data: data.map((item) => item.avgUsage.toFixed(2)), + data: data.map((item) => (item.avgUsage ?? 0).toFixed(2)), showArea: true, color: "#22c55e", // 초록색 }, { name: "최소 사용량", - data: data.map((item) => item.minUsage.toFixed(2)), + data: data.map((item) => (item.minUsage ?? 0).toFixed(2)), showArea: true, color: "#0ea5e9", // 하늘색 }, diff --git a/src/domains/mainDashboard/components/DiskUsageChart.tsx b/src/domains/mainDashboard/components/DiskUsageChart.tsx index 8f74e63..e6a8bb1 100644 --- a/src/domains/mainDashboard/components/DiskUsageChart.tsx +++ b/src/domains/mainDashboard/components/DiskUsageChart.tsx @@ -30,19 +30,19 @@ export default function DiskUsageChart({ data, height = '300px' }: DiskUsageChar const series: LineChartSeries[] = [ { name: '평균 사용량', - data: data.map((item) => item.avgUsage.toFixed(2)), + data: data.map((item) => (item.avgUsage ?? 0).toFixed(2)), showArea: true, color: '#22c55e', // 초록색 }, { name: '최대 사용량', - data: data.map((item) => item.maxUsage.toFixed(2)), + data: data.map((item) => (item.maxUsage ?? 0).toFixed(2)), showArea: true, color: '#eab308', // 노란색 }, { name: '최소 사용량', - data: data.map((item) => item.minUsage.toFixed(2)), + data: data.map((item) => (item.minUsage ?? 0).toFixed(2)), showArea: true, color: '#0ea5e9', // 하늘색 }, diff --git a/src/domains/mainDashboard/components/EquipmentTypeChart.tsx b/src/domains/mainDashboard/components/EquipmentTypeChart.tsx new file mode 100644 index 0000000..4899b9f --- /dev/null +++ b/src/domains/mainDashboard/components/EquipmentTypeChart.tsx @@ -0,0 +1,106 @@ +import { Cpu } from "lucide-react"; +import ReactECharts from "echarts-for-react"; + +interface EquipmentTypeData { + type: string; + count: number; +} + +interface EquipmentTypeChartProps { + data: EquipmentTypeData[]; +} + +// 장비 타입별 색상 매핑 +const EQUIPMENT_TYPE_COLORS: Record = { + SERVER: "#3b82f6", // blue-500 + STORAGE: "#8b5cf6", // violet-500 + SWITCH: "#10b981", // emerald-500 + ROUTER: "#f59e0b", // amber-500 + LOAD_BALANCER: "#ec4899", // pink-500 + FIREWALL: "#ef4444", // red-500 + KVM: "#14b8a6", // teal-500 + PDU: "#f97316", // orange-500 +}; + +// 장비 타입 한글 이름 매핑 +const EQUIPMENT_TYPE_LABELS: Record = { + SERVER: "서버", + STORAGE: "스토리지", + SWITCH: "스위치", + ROUTER: "라우터", + LOAD_BALANCER: "로드밸런서", + FIREWALL: "방화벽", + KVM: "KVM", + PDU: "PDU", +}; + +export default function EquipmentTypeChart({ data }: EquipmentTypeChartProps) { + // 차트 데이터 변환 + const chartData = data.map((item) => ({ + name: EQUIPMENT_TYPE_LABELS[item.type] || item.type, + value: item.count, + itemStyle: { + color: EQUIPMENT_TYPE_COLORS[item.type] || "#6b7280", + }, + })); + + const totalEquipments = data.reduce((sum, item) => sum + item.count, 0); + + const option = { + backgroundColor: "transparent", + tooltip: { + trigger: "item", + formatter: "{b}: {c}대 ({d}%)", + backgroundColor: "#1f2937", + borderColor: "#374151", + textStyle: { + color: "#f3f4f6", + }, + }, + legend: { + orient: "horizontal", + bottom: "0%", + textStyle: { + color: "#d1d5db", + fontSize: 12, + }, + }, + series: [ + { + type: "pie", + radius: ["40%", "70%"], + center: ["50%", "45%"], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 4, + borderColor: "#1f2937", + borderWidth: 2, + }, + label: { + show: false, + }, + labelLine: { + show: false, + }, + data: chartData, + }, + ], + }; + + return ( +
+
+ +

장비 타입별 구성

+
+ +
+ {totalEquipments}대 + 총 장비 수 +
+ + +
+ ); +} + diff --git a/src/domains/mainDashboard/components/RackDashboard.tsx b/src/domains/mainDashboard/components/RackDashboard.tsx index e85d56b..78f6054 100644 --- a/src/domains/mainDashboard/components/RackDashboard.tsx +++ b/src/domains/mainDashboard/components/RackDashboard.tsx @@ -1,89 +1,79 @@ -import { useState } from "react"; import { - useReactTable, - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, - type SortingState, -} from "@tanstack/react-table"; -import { DataTable, DataTablePagination } from "@/shared/table"; -import type { Rack } from "../types/dashboard.types"; -import { equipmentColumns } from "./equipmentTable.config"; -import { Layers, AlertTriangle } from "lucide-react"; + Layers, + AlertTriangle, + Thermometer, + Droplets, + Cpu, + HardDrive, + Network, +} from "lucide-react"; import { CpuGauge, MemoryGauge, DiskGauge, NetworkGauge } from "./index"; import { NetworkTrafficChart, - LoadAverageChart, - DiskIOChart, - ContextSwitchesSparkline, - NetworkErrorChart, - CpuUsageDetailChart, + CpuUsageChart, + TemperatureHumidityChart, + EquipmentTypeChart, + TopEquipmentsTable, } from "./index"; -import { - mockNetworkTrafficData, - mockLoadAverageData, - mockDiskIOData, - mockContextSwitchesData, - mockCpuUsageDetailData, -} from "../data/mockData"; +import { useRackSSE } from "@shared/hooks"; interface RackDashboardProps { - rack: Rack; + rackId: number; } -export default function RackDashboard({ rack }: RackDashboardProps) { - const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); - const [sorting, setSorting] = useState([]); +// 장비 타입별 U 높이 매핑 (SERVER, STORAGE는 2U, 나머지는 1U) +const EQUIPMENT_U_HEIGHT: Record = { + SERVER: 2, + STORAGE: 2, + SWITCH: 1, + ROUTER: 1, + LOAD_BALANCER: 1, + FIREWALL: 1, + KVM: 1, + PDU: 1, +}; + +export default function RackDashboard({ rackId }: RackDashboardProps) { + const { + metrics, + cpuUsageHistory, + networkTrafficHistory, + temperatureHumidityHistory, + error, + } = useRackSSE(rackId, true); + + // 로딩 상태 + if (!metrics || !metrics.rackSummary) { + return ( +
+
+
+

실시간 데이터를 불러오는 중...

+
+
+ ); + } - // 랙 메트릭 계산 - const totalEquipments = rack.equipments.length; - const usedU = rack.equipments.reduce((sum, eq) => sum + eq.height_u, 0); + // 랙 점유율 계산 (장비 타입별 U 높이 기반) + const usedU = metrics.rackSummary.activeEquipmentTypes.reduce( + (sum, eq) => sum + (EQUIPMENT_U_HEIGHT[eq.type] || 1) * eq.count, + 0 + ); const totalU = 42; // 기본 랙 높이 const rackUsagePercent = Math.round((usedU / totalU) * 1000) / 10; - const avgCpuUsage = - rack.equipments.reduce( - (sum, eq) => sum + (100 - (eq.systemMetric?.cpu_idle || 0)), - 0 - ) / totalEquipments || 0; - - const avgMemoryUsage = - rack.equipments.reduce( - (sum, eq) => sum + (eq.systemMetric?.used_memory_percentage || 0), - 0 - ) / totalEquipments || 0; - - const avgDiskUsage = - rack.equipments.reduce( - (sum, eq) => sum + (eq.storageMetric?.used_percentage || 0), - 0 - ) / totalEquipments || 0; - - const onlineCount = rack.equipments.filter( - (eq) => eq.status === "online" - ).length; - const warningCount = rack.equipments.filter( - (eq) => eq.status === "warning" - ).length; - const criticalCount = rack.equipments.filter( - (eq) => eq.status === "critical" - ).length; - - const table = useReactTable({ - data: rack.equipments, - columns: equipmentColumns, - state: { pagination, sorting }, - onPaginationChange: setPagination, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - }); - return (
+ {/* 에러 표시 */} + {error && ( +
+

{error}

+
+ )} + {/* 랙 상태 카드 */}
+ {/* 랙 점유율 */}
@@ -105,36 +95,39 @@ export default function RackDashboard({ rack }: RackDashboardProps) {
+ {/* 정상 */}

정상

- {onlineCount} + {metrics.rackSummary.normalCount}

+ {/* 경고 */}

경고

- {warningCount} + {metrics.rackSummary.warningCount}

+ {/* 위험 */}

위험

- {criticalCount} + {metrics.rackSummary.errorCount}

@@ -142,82 +135,263 @@ export default function RackDashboard({ rack }: RackDashboardProps) {
+ {/* 환경 정보 카드 */} +
+ {/* 온도 */} +
+
+ +

온도

+
+
+ + {metrics.environment.temperature.toFixed(1)}°C + +
+
+ 범위: {metrics.environment.minTemperature.toFixed(1)}°C ~{" "} + {metrics.environment.maxTemperature.toFixed(1)}°C +
+ {metrics.environment.temperatureWarning && ( +
+ ⚠️ {metrics.environment.temperatureWarning} +
+ )} +
+ + {/* 습도 */} +
+
+ +

습도

+
+
+ + {metrics.environment.humidity.toFixed(1)}% + +
+
+ 범위: {metrics.environment.minHumidity.toFixed(1)}% ~{" "} + {metrics.environment.maxHumidity.toFixed(1)}% +
+ {metrics.environment.humidityWarning && ( +
+ ⚠️ {metrics.environment.humidityWarning} +
+ )} +
+ + {/* 총 메모리 */} +
+
+ +

총 메모리

+
+
+ + {metrics.memoryStats.totalMemoryGB} + + GB +
+
+ 사용 중: {metrics.memoryStats.usedMemoryGB}GB +
+
+ + {/* 총 디스크 */} +
+
+ +

총 디스크

+
+
+ + {metrics.diskStats.totalCapacityTB.toFixed(2)} + + TB +
+
+ 사용 중: {metrics.diskStats.usedCapacityTB.toFixed(2)}TB +
+
+
+ {/* 게이지 차트 */}
- - - + + +
+ + {/* 장비 타입별 구성 & 네트워크 품질 */}
- {/* CPU 상세 사용률 */} - + {/* 장비 타입별 구성 */} + - {/* 시스템 부하 추세 */} - + {/* 네트워크 품질 지표 */} +
+
+ +

+ 네트워크 품질 지표 +

+
+ +
+ {/* 총 트래픽 */} +
+

총 네트워크 트래픽

+
+
+ RX: + + {metrics.networkStats.totalRxMbps.toFixed(2)} + + Mbps +
+
+ TX: + + {metrics.networkStats.totalTxMbps.toFixed(2)} + + Mbps +
+
+
+ + {/* 에러 패킷 비율 */} +
+

에러 패킷 비율

+
+ 0.001 + ? "text-red-400" + : "text-gray-100" + }`} + > + {(metrics.networkStats.errorPacketRate * 100).toFixed(4)}% + +
+
+ + {/* 드롭 패킷 비율 */} +
+

드롭 패킷 비율

+
+ 0.001 + ? "text-yellow-400" + : "text-gray-100" + }`} + > + {(metrics.networkStats.dropPacketRate * 100).toFixed(4)}% + +
+
+
+
- {/* 네트워크 및 디스크 I/O */} + {/* 온습도 추이 & CPU 사용률 추이 */}
- - + +
- {/* Context Switches 및 네트워크 에러/드롭 */} -
-
- -
- + {/* 네트워크 트래픽 추이 */} + + + {/* Top 장비 순위 테이블 */} +
+ {/* CPU Top 5 */} + } + /> + + {/* Memory Top 5 */} + } + /> +
+ +
+ {/* Disk Top 5 */} + } + /> + + {/* Network RX Top 5 */} + } + valueFormatter={(value) => `${value.toFixed(2)} Mbps`} + />
+ {/* Network TX Top 5 */} + } + valueFormatter={(value) => `${value.toFixed(2)} Mbps`} + /> + {/* 알람 */} - {(warningCount > 0 || criticalCount > 0) && ( + {(metrics.rackSummary.warningCount > 0 || + metrics.rackSummary.errorCount > 0) && (
활성 알람
- {criticalCount > 0 && ( + {metrics.rackSummary.errorCount > 0 && (
- • Critical: {criticalCount}개 장비 + • Critical: {metrics.rackSummary.errorCount}개 장비
)} - {warningCount > 0 && ( + {metrics.rackSummary.warningCount > 0 && (
- • Warning: {warningCount}개 장비 + • Warning: {metrics.rackSummary.warningCount}개 장비
)}
)} - - {/* 장비 테이블 */} -
- -
- -
-
); } + diff --git a/src/domains/mainDashboard/components/TopEquipmentsTable.tsx b/src/domains/mainDashboard/components/TopEquipmentsTable.tsx new file mode 100644 index 0000000..96bbc3f --- /dev/null +++ b/src/domains/mainDashboard/components/TopEquipmentsTable.tsx @@ -0,0 +1,85 @@ +interface TopEquipment { + equipmentId: number; + equipmentName: string; + value: number; +} + +interface TopEquipmentsTableProps { + title: string; + data: TopEquipment[]; + unit?: string; + icon?: React.ReactNode; + valueFormatter?: (value: number) => string; +} + +export default function TopEquipmentsTable({ + title, + data, + unit = "%", + icon, + valueFormatter, +}: TopEquipmentsTableProps) { + const formatValue = (value: number) => { + if (valueFormatter) { + return valueFormatter(value); + } + return `${value.toFixed(2)}${unit}`; + }; + + const getValueColor = (value: number, index: number) => { + if (unit === "%" && value >= 80) return "text-red-400"; + if (unit === "%" && value >= 60) return "text-yellow-400"; + if (index === 0) return "text-blue-400"; + return "text-gray-300"; + }; + + + if (!data || data.length === 0) { + return ( +
+
+ {icon} +

{title}

+
+
+ 데이터가 없습니다 +
+
+ ); + } + + return ( +
+
+ {icon} +

{title}

+
+ +
+ {data.map((equipment, index) => ( +
+
+
+ + #{index + 1} + +
+
+

+ {equipment.equipmentName} +

+

ID: {equipment.equipmentId}

+
+
+
+ {formatValue(equipment.value)} +
+
+ ))} +
+
+ ); +} diff --git a/src/domains/mainDashboard/components/index.ts b/src/domains/mainDashboard/components/index.ts index 6db2e9a..4c1ada0 100644 --- a/src/domains/mainDashboard/components/index.ts +++ b/src/domains/mainDashboard/components/index.ts @@ -16,6 +16,8 @@ export { default as ContextSwitchesSparkline } from './ContextSwitchesSparkline' export { default as NetworkErrorChart } from './NetworkErrorChart'; export { default as CpuUsageDetailChart } from './CpuUsageDetailChart'; export { default as TemperatureHumidityChart } from './TemperatureHumidityChart'; +export { default as EquipmentTypeChart } from './EquipmentTypeChart'; +export { default as TopEquipmentsTable } from './TopEquipmentsTable'; //다른 컴포넌트 export { default as ProgressGauge } from './ProgressGauge'; diff --git a/src/domains/mainDashboard/pages/MainDashboard.tsx b/src/domains/mainDashboard/pages/MainDashboard.tsx index 5af4cc5..7a2e103 100644 --- a/src/domains/mainDashboard/pages/MainDashboard.tsx +++ b/src/domains/mainDashboard/pages/MainDashboard.tsx @@ -68,11 +68,7 @@ function MainDashboard() { } if (selectedNode.level === 'rack' && selectedNode.serverRoomId && selectedNode.rackId) { - const serverRoom = datacenter.serverRooms.find((sr) => sr.id === selectedNode.serverRoomId); - const rack = serverRoom?.racks.find((r) => r.id === selectedNode.rackId); - if (!serverRoom || !rack) return
랙을 찾을 수 없습니다.
; - - return ; + return ; } return null; @@ -91,7 +87,7 @@ function MainDashboard() { return (
{/* 왼쪽 사이드바 */} -
+
{selectedNode && ( ; + }; + + // CPU 통계 + cpuStats: { + avgUsage: number; + maxUsage: number; + topEquipments: Array<{ + equipmentId: number; + equipmentName: string; + value: number; + }>; + equipmentCount: number; + }; + + // 메모리 통계 + memoryStats: { + avgUsage: number; + maxUsage: number; + topEquipments: Array<{ + equipmentId: number; + equipmentName: string; + value: number; + }>; + equipmentCount: number; + totalMemoryGB: number; + usedMemoryGB: number; + }; + + // 디스크 통계 + diskStats: { + avgUsage: number; + maxUsage: number; + topEquipments: Array<{ + equipmentId: number; + equipmentName: string; + value: number; + }>; + equipmentCount: number; + totalCapacityTB: number; + usedCapacityTB: number; + }; + + // 네트워크 통계 + networkStats: { + totalRxMbps: number; + totalTxMbps: number; + avgRxUsage: number; + avgTxUsage: number; + topRxEquipments: Array<{ + equipmentId: number; + equipmentName: string; + value: number; + }>; + topTxEquipments: Array<{ + equipmentId: number; + equipmentName: string; + value: number; + }>; + errorPacketRate: number; + dropPacketRate: number; + equipmentCount: number; + }; +} + // 시계열 데이터 포인트 export interface TimeSeriesDataPoint { timestamp: string; diff --git a/src/domains/serverView/api/historyApi.ts b/src/domains/serverView/api/historyApi.ts new file mode 100644 index 0000000..21ab4a7 --- /dev/null +++ b/src/domains/serverView/api/historyApi.ts @@ -0,0 +1,66 @@ +import client from "@/api/client"; + +export interface HistoryRecord { + id: number; + serverRoomId: number; + entityType: "EQUIPMENT" | "RACK" | "SENSOR" | "OTHER"; + entityId: number; + entityName: string; + entityCode: string | null; + action: "CREATE" | "UPDATE" | "DELETE"; + changedBy: number; + changedByName: string; + changedByRole: string; + changedAt: string; + changedFields: string[]; + beforeValue: Record | null; + afterValue: Record | null; + description: string | null; +} + +export interface HistoryResponse { + status_code: number; + status_message: string; + result: { + content: HistoryRecord[]; + pageable: { + pageNumber: number; + pageSize: number; + offset: number; + paged: boolean; + unpaged: boolean; + }; + totalElements: number; + totalPages: number; + last: boolean; + first: boolean; + numberOfElements: number; + size: number; + number: number; + empty: boolean; + }; +} + +export interface HistoryParams { + page?: number; + size?: number; + entityType?: string; + action?: string; +} + +export const historyApi = { + // 서버실 히스토리 조회 + getServerRoomHistory: async ( + serverRoomId: number, + params: HistoryParams = {} + ): Promise => { + const { page = 0, size = 20, entityType, action } = params; + const response = await client.get( + `/history/serverroom/${serverRoomId}`, + { + params: { page, size, entityType, action }, + } + ); + return response.data; + }, +}; diff --git a/src/domains/serverView/components/ServerRoomHistoryPanel.tsx b/src/domains/serverView/components/ServerRoomHistoryPanel.tsx new file mode 100644 index 0000000..cab6a8b --- /dev/null +++ b/src/domains/serverView/components/ServerRoomHistoryPanel.tsx @@ -0,0 +1,348 @@ +import { useState, useEffect, useRef } from "react"; +import { MdHistory, MdClose, MdFilterList } from "react-icons/md"; +import { FiGitCommit } from "react-icons/fi"; +import { BiPlus, BiMinus, BiEdit } from "react-icons/bi"; +import { historyApi, type HistoryRecord } from "../api/historyApi"; + +interface ServerRoomHistoryPanelProps { + serverRoomId: number; +} + +function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { + const [isOpen, setIsOpen] = useState(false); + const [history, setHistory] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [selectedAction, setSelectedAction] = useState("ALL"); + const [selectedEntityType, setSelectedEntityType] = useState("ALL"); + const scrollRef = useRef(null); + + // 필터 변경 시 리셋 + useEffect(() => { + if (!isOpen) return; + + const loadHistory = async () => { + try { + setIsLoading(true); + setPage(0); + + const params = { + page: 0, + size: 20, + ...(selectedAction !== "ALL" && { action: selectedAction }), + ...(selectedEntityType !== "ALL" && { entityType: selectedEntityType }), + }; + + const response = await historyApi.getServerRoomHistory(serverRoomId, params); + setHistory(response.result.content); + setHasMore(!response.result.last); + } catch (error) { + console.error("Failed to fetch history:", error); + } finally { + setIsLoading(false); + } + }; + + loadHistory(); + }, [isOpen, selectedAction, selectedEntityType, serverRoomId]); + + // 페이지 변경 시 추가 로드 + useEffect(() => { + if (page === 0 || !isOpen) return; + + const loadMore = async () => { + try { + setIsLoading(true); + + const params = { + page, + size: 20, + ...(selectedAction !== "ALL" && { action: selectedAction }), + ...(selectedEntityType !== "ALL" && { entityType: selectedEntityType }), + }; + + const response = await historyApi.getServerRoomHistory(serverRoomId, params); + setHistory((prev) => [...prev, ...response.result.content]); + setHasMore(!response.result.last); + } catch (error) { + console.error("Failed to fetch history:", error); + } finally { + setIsLoading(false); + } + }; + + loadMore(); + }, [page, isOpen, selectedAction, selectedEntityType, serverRoomId]); + + // 무한 스크롤 + const handleScroll = () => { + if (!scrollRef.current || isLoading || !hasMore) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + if (scrollHeight - scrollTop <= clientHeight + 100) { + setPage((prev) => prev + 1); + } + }; + + const getActionIcon = (action: string) => { + switch (action) { + case "CREATE": + return ; + case "DELETE": + return ; + case "UPDATE": + return ; + default: + return ; + } + }; + + const getActionColor = (action: string) => { + switch (action) { + case "CREATE": + return "text-green-500 bg-green-500/10 border-green-500/30"; + case "DELETE": + return "text-red-500 bg-red-500/10 border-red-500/30"; + case "UPDATE": + return "text-blue-500 bg-blue-500/10 border-blue-500/30"; + default: + return "text-gray-500 bg-gray-500/10 border-gray-500/30"; + } + }; + + const formatTimestamp = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "방금 전"; + if (minutes < 60) return `${minutes}분 전`; + if (hours < 24) return `${hours}시간 전`; + if (days < 30) return `${days}일 전`; + return date.toLocaleDateString("ko-KR"); + }; + + const renderChangedFields = (record: HistoryRecord) => { + if (record.action === "CREATE" || record.action === "DELETE") { + return null; + } + + return ( +
+ 변경된 필드: + + {record.changedFields.join(", ")} + +
+ ); + }; + + const renderValueDiff = (record: HistoryRecord) => { + if (record.action === "CREATE" && record.afterValue) { + return ( +
+
+ 생성됨
+
+            {JSON.stringify(record.afterValue, null, 2)}
+          
+
+ ); + } + + if (record.action === "DELETE" && record.beforeValue) { + return ( +
+
- 삭제됨
+
+            {JSON.stringify(record.beforeValue, null, 2)}
+          
+
+ ); + } + + if (record.action === "UPDATE" && record.beforeValue && record.afterValue) { + const changedFieldsData: Record = {}; + + record.changedFields.forEach((field) => { + if (field !== "ALL") { + changedFieldsData[field] = { + before: record.beforeValue?.[field], + after: record.afterValue?.[field], + }; + } + }); + + return ( +
+ {Object.entries(changedFieldsData).map(([field, values]) => ( +
+
{field}
+
+ + {String(values.before ?? "null")} + + + + {String(values.after ?? "null")} + +
+
+ ))} +
+ ); + } + + return null; + }; + + return ( + <> + {/* 히스토리 토글 버튼 */} + + + {/* 히스토리 패널 */} + {isOpen && ( +
+ {/* 헤더 */} +
+
+ +

변경 히스토리

+
+ +
+ + {/* 필터 */} +
+
+ + 필터 +
+
+ + +
+
+ + {/* 히스토리 목록 */} +
+ {history.length === 0 && !isLoading ? ( +
+ 히스토리가 없습니다. +
+ ) : ( + history.map((record, index) => ( +
+ {/* 타임라인 라인 */} + {index < history.length - 1 && ( +
+ )} + + {/* 타임라인 아이콘 */} +
+ {getActionIcon(record.action)} +
+ + {/* 커밋 내용 */} +
+ {/* 헤더 */} +
+
+
+ + {record.action} + + + {record.entityType} + +
+

+ {record.entityName} +

+
+
+ + {/* 작성자 및 시간 */} +
+ + {record.changedByName} + + ({record.changedByRole}) + + {formatTimestamp(record.changedAt)} +
+ + {/* 변경된 필드 */} + {renderChangedFields(record)} + + {/* 값 변경 상세 */} + {renderValueDiff(record)} +
+
+ )) + )} + + {isLoading && ( +
+
+
+ )} +
+
+ )} + + ); +} + +export default ServerRoomHistoryPanel; diff --git a/src/domains/serverView/pages/ServerViewPage.tsx b/src/domains/serverView/pages/ServerViewPage.tsx index b551efe..6dc8aa5 100644 --- a/src/domains/serverView/pages/ServerViewPage.tsx +++ b/src/domains/serverView/pages/ServerViewPage.tsx @@ -2,6 +2,7 @@ import { useState, useRef } from 'react'; import { useParams } from 'react-router-dom'; import ServerViewHeader from '../components/ServerViewHeader'; import ServerRoomStatsPanel from '../components/ServerRoomStatsPanel'; +import ServerRoomHistoryPanel from '../components/ServerRoomHistoryPanel'; import BabylonDatacenterView from '../view3d/components/BabylonDatacenterView'; import RackModal from '../components/RackModal'; import FloorPlanPage from '../floorPlan/pages/FloorPlanPage'; @@ -34,11 +35,13 @@ function ServerViewPage() { {viewDimension === '3D' ? (
{id && } + {id && }
) : (
{id && } + {id && }
)} diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 3bd695d..7edbaa3 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,4 +1,6 @@ export { useDatacenterSSE } from './useDatacenterSSE'; export { useServerRoomSSE } from './useServerRoomSSE'; +export { useRackSSE } from './useRackSSE'; export type { UseDatacenterSSEResult } from './useDatacenterSSE'; export type { UseServerRoomSSEResult } from './useServerRoomSSE'; +export type { UseRackSSEResult } from './useRackSSE'; diff --git a/src/shared/hooks/useRackSSE.ts b/src/shared/hooks/useRackSSE.ts new file mode 100644 index 0000000..e4c64ce --- /dev/null +++ b/src/shared/hooks/useRackSSE.ts @@ -0,0 +1,160 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { createRackSSE, type SSEConnection } from "@api/sseClient"; +import type { + RackMetrics, + CpuUsageData, + NetworkUsageTrend, + TemperatureHumidityData, +} from "@domains/mainDashboard/types/dashboard.types"; + +const MAX_HISTORY_POINTS = 20; // 차트에 표시할 최대 데이터 포인트 수 + +export interface UseRackSSEResult { + // 현재 메트릭 + metrics: RackMetrics | null; + + // 시계열 데이터 + cpuUsageHistory: CpuUsageData[]; + networkTrafficHistory: NetworkUsageTrend[]; + temperatureHumidityHistory: TemperatureHumidityData[]; + + // 연결 상태 + isConnected: boolean; + error: string | null; + + // 제어 함수 + reconnect: () => void; +} + +/** + * 랙 실시간 모니터링 SSE Hook + * @param rackId 랙 ID + * @param enabled 연결 활성화 여부 + */ +export const useRackSSE = ( + rackId: number | null, + enabled: boolean = true +): UseRackSSEResult => { + const [metrics, setMetrics] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + + // 시계열 데이터 상태 + const [cpuUsageHistory, setCpuUsageHistory] = useState([]); + const [networkTrafficHistory, setNetworkTrafficHistory] = useState([]); + const [temperatureHumidityHistory, setTemperatureHumidityHistory] = useState([]); + + const sseConnectionRef = useRef(null); + + // SSE 메시지 처리 + const handleMessage = useCallback((data: RackMetrics) => { + // 데이터 유효성 검증 + if (!data || !data.cpuStats || !data.networkStats || !data.environment) { + console.warn('Incomplete SSE data received:', data); + return; + } + + setMetrics(data); + setError(null); + + // CPU 사용량 데이터 추가 + setCpuUsageHistory((prev) => { + const cpuUsage: CpuUsageData = { + time: data.timestamp, + avgUsage: data.cpuStats.avgUsage, + maxUsage: data.cpuStats.maxUsage, + minUsage: 0, // 랙 데이터에는 min이 없으므로 0으로 설정 + }; + const updated = [...prev, cpuUsage]; + if (updated.length > MAX_HISTORY_POINTS) { + return updated.slice(updated.length - MAX_HISTORY_POINTS); + } + return updated; + }); + + // 네트워크 트래픽 데이터 추가 + setNetworkTrafficHistory((prev) => { + const networkTraffic: NetworkUsageTrend = { + time: data.timestamp, + rxBytesPerSec: data.networkStats.totalRxMbps * 1000000 / 8, // Mbps to bytes/sec + txBytesPerSec: data.networkStats.totalTxMbps * 1000000 / 8, + }; + const updated = [...prev, networkTraffic]; + if (updated.length > MAX_HISTORY_POINTS) { + return updated.slice(updated.length - MAX_HISTORY_POINTS); + } + return updated; + }); + + // 온습도 데이터 추가 + setTemperatureHumidityHistory((prev) => { + const tempHumidity: TemperatureHumidityData = { + time: data.timestamp, + temperature: data.environment.temperature, + humidity: data.environment.humidity, + }; + const updated = [...prev, tempHumidity]; + if (updated.length > MAX_HISTORY_POINTS) { + return updated.slice(updated.length - MAX_HISTORY_POINTS); + } + return updated; + }); + }, []); + + // SSE 연결 관리 + useEffect(() => { + if (!enabled || rackId === null) { + // 연결 해제 + if (sseConnectionRef.current) { + sseConnectionRef.current.close(); + sseConnectionRef.current = null; + } + setIsConnected(false); + return; + } + + // SSE 연결 생성 + sseConnectionRef.current = createRackSSE(rackId, { + onMessage: handleMessage, + onError: (error) => { + console.error("SSE Error:", error); + setError("실시간 연결에 문제가 발생했습니다."); + setIsConnected(false); + }, + onOpen: () => { + console.log(`Connected to rack ${rackId} SSE`); + setIsConnected(true); + setError(null); + }, + reconnectDelay: 3000, + maxReconnectAttempts: 10, + }); + + // 클린업 + return () => { + if (sseConnectionRef.current) { + sseConnectionRef.current.close(); + sseConnectionRef.current = null; + } + setIsConnected(false); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rackId, enabled]); + + // 수동 재연결 함수 + const reconnect = useCallback(() => { + if (sseConnectionRef.current) { + sseConnectionRef.current.reconnect(); + } + }, []); + + return { + metrics, + cpuUsageHistory, + networkTrafficHistory, + temperatureHumidityHistory, + isConnected, + error, + reconnect, + }; +}; diff --git a/src/shared/layout/Header.tsx b/src/shared/layout/Header.tsx index 6788116..0ef13be 100644 --- a/src/shared/layout/Header.tsx +++ b/src/shared/layout/Header.tsx @@ -4,6 +4,7 @@ import { GrResources } from "react-icons/gr"; import { LuLayoutDashboard } from "react-icons/lu"; import { MdOutlinePeopleAlt, MdLogout } from "react-icons/md"; import { useAuthStore } from "@domains/login/store/useAuthStore"; +import NotificationBell from "./NotificationBell"; function Header() { const navigate = useNavigate(); @@ -85,6 +86,9 @@ function Header() {
+ {/* 알림 버튼 */} + + {/* 로그아웃 버튼 */} + + {/* 알림 드롭다운 */} + {isOpen && ( +
+ +
+ {alerts.length === 0 ? ( +
+ 새로운 알림이 없습니다. +
+ ) : ( +
+ {alerts.map((alert) => ( +
+
+
+
+
+ + {alert.level} + +

+ {alert.targetName} +

+
+ {!alert.isRead && ( + + )} +
+

+ {alert.message} +

+ + {formatTimestamp(alert.triggeredAt)} + +
+
+
+ ))} +
+ )} +
+ + {alerts.length > 0 && ( +
+ +
+ )} +
+ )} +
+ ); +} + +export default NotificationBell; From 82d4cf03666ce6f5a50e823878581531c9c0b394 Mon Sep 17 00:00:00 2001 From: DHowor1d Date: Tue, 25 Nov 2025 07:14:17 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=97=90=EB=9F=AC=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mainDashboard/api/mainDashboardApi.ts | 4 + .../components/DashboardEmptyFallback.tsx | 48 +++++++++ .../components/DatacenterDashboard.tsx | 5 + .../components/HierarchySidebar.tsx | 2 +- .../components/RackDashboard.tsx | 72 +++++++------ .../components/ServerRoomDashboard.tsx | 5 + src/domains/mainDashboard/components/index.ts | 1 + .../mainDashboard/pages/MainDashboard.tsx | 13 ++- .../mainDashboard/types/dashboard.types.ts | 1 + src/domains/serverView/api/historyApi.ts | 2 +- .../components/ServerRoomHistoryPanel.tsx | 101 +++++++++--------- src/shared/error/EmptyStateFallback.tsx | 53 +++++++++ src/shared/error/ErrorFallback.tsx | 55 ++++++---- src/shared/error/index.ts | 1 + src/shared/hooks/useDatacenterSSE.ts | 36 +++++++ src/shared/hooks/useRackSSE.ts | 36 +++++++ src/shared/hooks/useServerRoomSSE.ts | 36 +++++++ src/shared/layout/NotificationBell.tsx | 2 +- 18 files changed, 367 insertions(+), 106 deletions(-) create mode 100644 src/domains/mainDashboard/components/DashboardEmptyFallback.tsx create mode 100644 src/shared/error/EmptyStateFallback.tsx diff --git a/src/domains/mainDashboard/api/mainDashboardApi.ts b/src/domains/mainDashboard/api/mainDashboardApi.ts index 24817db..4895135 100644 --- a/src/domains/mainDashboard/api/mainDashboardApi.ts +++ b/src/domains/mainDashboard/api/mainDashboardApi.ts @@ -16,6 +16,7 @@ export interface Device { status: string; rackName: string | null; rackId: number | null; + equipmentCount: number; } // 서버실 정보 @@ -47,6 +48,7 @@ export interface Rack { gridZ: number; rotation: number; status: string; + equipmentCount: number; // 랙 내 장비 개수 equipments: []; // 빈 배열로 초기화 (필요시 추가 API로 장비 정보 로드) } @@ -121,6 +123,7 @@ export const getServerRoomRacks = async ( const { devices } = await getServerRoomDevices(serverRoomId); // deviceType이 "server"인 것만 필터링하여 Rack으로 변환 + // API 응답에 이미 equipmentCount가 포함되어 있음 return devices .filter((device) => device.deviceType === "server") .map((device) => ({ @@ -133,6 +136,7 @@ export const getServerRoomRacks = async ( gridZ: device.gridZ, rotation: device.rotation, status: device.status, + equipmentCount: device.equipmentCount, // API 응답의 equipmentCount 사용 equipments: [], // 빈 배열로 초기화 })); }; diff --git a/src/domains/mainDashboard/components/DashboardEmptyFallback.tsx b/src/domains/mainDashboard/components/DashboardEmptyFallback.tsx new file mode 100644 index 0000000..7d99011 --- /dev/null +++ b/src/domains/mainDashboard/components/DashboardEmptyFallback.tsx @@ -0,0 +1,48 @@ +import { PackageOpen } from 'lucide-react'; + +interface DashboardEmptyFallbackProps { + error: Error; +} + +/** + * 메인 대시보드 전용 Empty State Fallback + * 장비가 없는 경우 표시되는 UI + */ +function DashboardEmptyFallback({ error }: DashboardEmptyFallbackProps) { + // 에러 메시지에서 어떤 위치인지 파악 + let title = '배치된 장비가 없습니다'; + let message = '이 위치에 장비를 배치하면 실시간 모니터링 데이터를 확인할 수 있습니다.'; + + if (error.message.includes('데이터센터')) { + title = '데이터센터에 배치된 장비가 없습니다'; + message = '서버실에 장비를 배치하면 데이터센터의 실시간 모니터링 데이터를 확인할 수 있습니다.'; + } else if (error.message.includes('서버실')) { + title = '서버실에 배치된 장비가 없습니다'; + message = '랙에 장비를 배치하면 서버실의 실시간 모니터링 데이터를 확인할 수 있습니다.'; + } else if (error.message.includes('랙')) { + title = '랙에 배치된 장비가 없습니다'; + message = '이 랙에 장비를 배치하면 실시간 모니터링 데이터를 확인할 수 있습니다.'; + } + + return ( +
+ + +

+ {title} +

+ +

+ {message} +

+ +
+

+ 💡 장비 배치는 서버실 뷰에서 할 수 있습니다 +

+
+
+ ); +} + +export default DashboardEmptyFallback; diff --git a/src/domains/mainDashboard/components/DatacenterDashboard.tsx b/src/domains/mainDashboard/components/DatacenterDashboard.tsx index e4a25d1..00f6ef7 100644 --- a/src/domains/mainDashboard/components/DatacenterDashboard.tsx +++ b/src/domains/mainDashboard/components/DatacenterDashboard.tsx @@ -30,6 +30,11 @@ export default function DatacenterDashboard({ // reconnect, } = useDatacenterSSE(datacenterId, true); + // 에러 발생 시 throw하여 ErrorBoundary가 처리하도록 함 + if (error) { + throw new Error(error); + } + // 로딩 상태 if (!metrics) { return ( diff --git a/src/domains/mainDashboard/components/HierarchySidebar.tsx b/src/domains/mainDashboard/components/HierarchySidebar.tsx index 298755a..fbbcc3f 100644 --- a/src/domains/mainDashboard/components/HierarchySidebar.tsx +++ b/src/domains/mainDashboard/components/HierarchySidebar.tsx @@ -170,7 +170,7 @@ export default function HierarchySidebar({ {rack.name} - ({rack.equipments?.length || 0}) + ({rack.equipmentCount})
} diff --git a/src/domains/mainDashboard/components/RackDashboard.tsx b/src/domains/mainDashboard/components/RackDashboard.tsx index 78f6054..1c6a809 100644 --- a/src/domains/mainDashboard/components/RackDashboard.tsx +++ b/src/domains/mainDashboard/components/RackDashboard.tsx @@ -42,6 +42,11 @@ export default function RackDashboard({ rackId }: RackDashboardProps) { error, } = useRackSSE(rackId, true); + // 에러 발생 시 throw하여 ErrorBoundary가 처리하도록 함 + if (error) { + throw new Error(error); + } + // 로딩 상태 if (!metrics || !metrics.rackSummary) { return ( @@ -222,16 +227,48 @@ export default function RackDashboard({ rackId }: RackDashboardProps) {
- {/* 장비 타입별 구성 & 네트워크 품질 */} -
+ {/* 온습도 추이 & CPU 사용률 추이 */} +
{/* 장비 타입별 구성 */} +
+ +
+
+ +
+
+ +
+ {/* 네트워크 트래픽 추이 */} + - {/* 네트워크 품질 지표 */}
@@ -296,32 +333,6 @@ export default function RackDashboard({ rackId }: RackDashboardProps) {
- - {/* 온습도 추이 & CPU 사용률 추이 */} -
- - -
- - {/* 네트워크 트래픽 추이 */} - - {/* Top 장비 순위 테이블 */}
{/* CPU Top 5 */} @@ -394,4 +405,3 @@ export default function RackDashboard({ rackId }: RackDashboardProps) {
); } - diff --git a/src/domains/mainDashboard/components/ServerRoomDashboard.tsx b/src/domains/mainDashboard/components/ServerRoomDashboard.tsx index 2fcd7e4..2b0d524 100644 --- a/src/domains/mainDashboard/components/ServerRoomDashboard.tsx +++ b/src/domains/mainDashboard/components/ServerRoomDashboard.tsx @@ -28,6 +28,11 @@ export default function ServerRoomDashboard({ error, } = useServerRoomSSE(serverRoomId, true); + // 에러 발생 시 throw하여 ErrorBoundary가 처리하도록 함 + if (error) { + throw new Error(error); + } + // 로딩 상태 if (!metrics) { return ( diff --git a/src/domains/mainDashboard/components/index.ts b/src/domains/mainDashboard/components/index.ts index 4c1ada0..e9af3d9 100644 --- a/src/domains/mainDashboard/components/index.ts +++ b/src/domains/mainDashboard/components/index.ts @@ -26,3 +26,4 @@ export { default as HierarchySidebar } from './HierarchySidebar'; export { default as DatacenterDashboard } from './DatacenterDashboard'; export { default as ServerRoomDashboard } from './ServerRoomDashboard'; export { default as RackDashboard } from './RackDashboard'; +export { default as DashboardEmptyFallback } from './DashboardEmptyFallback'; diff --git a/src/domains/mainDashboard/pages/MainDashboard.tsx b/src/domains/mainDashboard/pages/MainDashboard.tsx index 7a2e103..cfd7cb7 100644 --- a/src/domains/mainDashboard/pages/MainDashboard.tsx +++ b/src/domains/mainDashboard/pages/MainDashboard.tsx @@ -4,8 +4,10 @@ import Breadcrumb from '../components/Breadcrumb'; import DatacenterDashboard from '../components/DatacenterDashboard'; import ServerRoomDashboard from '../components/ServerRoomDashboard'; import RackDashboard from '../components/RackDashboard'; +import DashboardEmptyFallback from '../components/DashboardEmptyFallback'; import { useDashboardData } from '../hooks/useDashboardData'; import type { SelectedNode } from '../types/dashboard.types'; +import { ErrorBoundary } from '@shared/error'; function MainDashboard() { const COMPANY_ID = 1; // TODO: 실제 로그인 회사 ID로 교체 @@ -105,7 +107,16 @@ function MainDashboard() { <>
-
{renderDashboard()}
+
+ ( + + )} + > + {renderDashboard()} + +
)} diff --git a/src/domains/mainDashboard/types/dashboard.types.ts b/src/domains/mainDashboard/types/dashboard.types.ts index ba21d0b..66d6914 100644 --- a/src/domains/mainDashboard/types/dashboard.types.ts +++ b/src/domains/mainDashboard/types/dashboard.types.ts @@ -97,6 +97,7 @@ export interface Rack { gridZ: number; rotation: number; status: string; + equipmentCount: number; equipments: Equipment[]; } diff --git a/src/domains/serverView/api/historyApi.ts b/src/domains/serverView/api/historyApi.ts index 21ab4a7..174a375 100644 --- a/src/domains/serverView/api/historyApi.ts +++ b/src/domains/serverView/api/historyApi.ts @@ -3,7 +3,7 @@ import client from "@/api/client"; export interface HistoryRecord { id: number; serverRoomId: number; - entityType: "EQUIPMENT" | "RACK" | "SENSOR" | "OTHER"; + entityType: "EQUIPMENT" | "RACK" | "DEVICE"; entityId: number; entityName: string; entityCode: string | null; diff --git a/src/domains/serverView/components/ServerRoomHistoryPanel.tsx b/src/domains/serverView/components/ServerRoomHistoryPanel.tsx index cab6a8b..8d6f91b 100644 --- a/src/domains/serverView/components/ServerRoomHistoryPanel.tsx +++ b/src/domains/serverView/components/ServerRoomHistoryPanel.tsx @@ -10,7 +10,7 @@ interface ServerRoomHistoryPanelProps { function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { const [isOpen, setIsOpen] = useState(false); - const [history, setHistory] = useState([]); + const [allHistory, setAllHistory] = useState([]); const [isLoading, setIsLoading] = useState(false); const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); @@ -18,7 +18,18 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { const [selectedEntityType, setSelectedEntityType] = useState("ALL"); const scrollRef = useRef(null); - // 필터 변경 시 리셋 + // 클라이언트 사이드 필터링 + const filteredHistory = allHistory.filter((record) => { + if (selectedAction !== "ALL" && record.action !== selectedAction) { + return false; + } + if (selectedEntityType !== "ALL" && record.entityType !== selectedEntityType) { + return false; + } + return true; + }); + + // 패널 열릴 때만 초기 로드 useEffect(() => { if (!isOpen) return; @@ -27,15 +38,11 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { setIsLoading(true); setPage(0); - const params = { + const response = await historyApi.getServerRoomHistory(serverRoomId, { page: 0, size: 20, - ...(selectedAction !== "ALL" && { action: selectedAction }), - ...(selectedEntityType !== "ALL" && { entityType: selectedEntityType }), - }; - - const response = await historyApi.getServerRoomHistory(serverRoomId, params); - setHistory(response.result.content); + }); + setAllHistory(response.result.content); setHasMore(!response.result.last); } catch (error) { console.error("Failed to fetch history:", error); @@ -45,7 +52,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { }; loadHistory(); - }, [isOpen, selectedAction, selectedEntityType, serverRoomId]); + }, [isOpen, serverRoomId]); // 페이지 변경 시 추가 로드 useEffect(() => { @@ -55,15 +62,11 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { try { setIsLoading(true); - const params = { + const response = await historyApi.getServerRoomHistory(serverRoomId, { page, size: 20, - ...(selectedAction !== "ALL" && { action: selectedAction }), - ...(selectedEntityType !== "ALL" && { entityType: selectedEntityType }), - }; - - const response = await historyApi.getServerRoomHistory(serverRoomId, params); - setHistory((prev) => [...prev, ...response.result.content]); + }); + setAllHistory((prev) => [...prev, ...response.result.content]); setHasMore(!response.result.last); } catch (error) { console.error("Failed to fetch history:", error); @@ -73,7 +76,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { }; loadMore(); - }, [page, isOpen, selectedAction, selectedEntityType, serverRoomId]); + }, [page, isOpen, serverRoomId]); // 무한 스크롤 const handleScroll = () => { @@ -88,26 +91,26 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { const getActionIcon = (action: string) => { switch (action) { case "CREATE": - return ; + return ; case "DELETE": - return ; + return ; case "UPDATE": - return ; + return ; default: - return ; + return ; } }; const getActionColor = (action: string) => { switch (action) { case "CREATE": - return "text-green-500 bg-green-500/10 border-green-500/30"; + return "text-green-400 bg-green-500/20 border-green-500/40"; case "DELETE": - return "text-red-500 bg-red-500/10 border-red-500/30"; + return "text-red-400 bg-red-500/20 border-red-500/40"; case "UPDATE": - return "text-blue-500 bg-blue-500/10 border-blue-500/30"; + return "text-cyan-400 bg-cyan-500/20 border-cyan-500/40"; default: - return "text-gray-500 bg-gray-500/10 border-gray-500/30"; + return "text-gray-400 bg-gray-500/20 border-gray-500/40"; } }; @@ -134,7 +137,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { return (
변경된 필드: - + {record.changedFields.join(", ")}
@@ -144,7 +147,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { const renderValueDiff = (record: HistoryRecord) => { if (record.action === "CREATE" && record.afterValue) { return ( -
+
+ 생성됨
             {JSON.stringify(record.afterValue, null, 2)}
@@ -155,7 +158,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) {
 
     if (record.action === "DELETE" && record.beforeValue) {
       return (
-        
+
- 삭제됨
             {JSON.stringify(record.beforeValue, null, 2)}
@@ -181,9 +184,9 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) {
           {Object.entries(changedFieldsData).map(([field, values]) => (
             
-
{field}
+
{field}
{String(values.before ?? "null")} @@ -215,23 +218,23 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { {/* 히스토리 패널 */} {isOpen && ( -
+
{/* 헤더 */} -
+
- -

변경 히스토리

+ +

변경 히스토리

{/* 필터 */} -
+
필터 @@ -240,7 +243,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { setSelectedEntityType(e.target.value)} - className="flex-1 px-3 py-1.5 bg-gray-800 text-white text-sm rounded border border-gray-700 focus:border-blue-500 focus:outline-none" + className="flex-1 px-3 py-1.5 bg-neutral-800 text-gray-100 text-sm rounded border border-neutral-700 focus:border-cyan-500 focus:outline-none" > - + - +
@@ -266,19 +269,19 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { onScroll={handleScroll} className="flex-1 overflow-y-auto p-4 space-y-4" > - {history.length === 0 && !isLoading ? ( + {filteredHistory.length === 0 && !isLoading ? (
- 히스토리가 없습니다. + {allHistory.length === 0 ? "히스토리가 없습니다." : "필터 조건에 맞는 히스토리가 없습니다."}
) : ( - history.map((record, index) => ( + filteredHistory.map((record, index) => (
{/* 타임라인 라인 */} - {index < history.length - 1 && ( -
+ {index < filteredHistory.length - 1 && ( +
)} {/* 타임라인 아이콘 */} @@ -291,7 +294,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) {
{/* 커밋 내용 */} -
+
{/* 헤더 */}
@@ -315,7 +318,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { {/* 작성자 및 시간 */}
- + {record.changedByName} ({record.changedByRole}) @@ -335,7 +338,7 @@ function ServerRoomHistoryPanel({ serverRoomId }: ServerRoomHistoryPanelProps) { {isLoading && (
-
+
)}
diff --git a/src/shared/error/EmptyStateFallback.tsx b/src/shared/error/EmptyStateFallback.tsx new file mode 100644 index 0000000..5ca4e1a --- /dev/null +++ b/src/shared/error/EmptyStateFallback.tsx @@ -0,0 +1,53 @@ +import { PackageOpen, RefreshCw } from 'lucide-react'; + +interface EmptyStateFallbackProps { + title?: string; + message?: string; + onRetry?: () => void; + showRetry?: boolean; +} + +/** + * 데이터 없음 상태를 표시하는 Fallback UI + * 에러가 아닌 정상적인 빈 상태를 사용자에게 안내 + */ +function EmptyStateFallback({ + title = '데이터가 없습니다', + message = '이 위치에 배치된 장비가 없습니다.', + onRetry, + showRetry = true, +}: EmptyStateFallbackProps) { + const handleRetry = () => { + if (onRetry) { + onRetry(); + } else { + window.location.reload(); + } + }; + + return ( +
+ + +

+ {title} +

+ +

+ {message} +

+ + {showRetry && ( + + )} +
+ ); +} + +export default EmptyStateFallback; diff --git a/src/shared/error/ErrorFallback.tsx b/src/shared/error/ErrorFallback.tsx index 5819eaf..106898c 100644 --- a/src/shared/error/ErrorFallback.tsx +++ b/src/shared/error/ErrorFallback.tsx @@ -1,4 +1,4 @@ -import { TriangleAlert, RefreshCw } from 'lucide-react'; +import { PackageOpen, RefreshCw } from 'lucide-react'; interface ErrorFallbackProps { error?: Error; @@ -12,10 +12,10 @@ interface ErrorFallbackProps { * 에러 발생 시 사용자에게 표시되는 UI */ function ErrorFallback({ - // error, + error, resetError, - title = '오류가 발생했습니다', - message = '일시적인 문제가 발생했습니다. 다시 시도해 주세요.' + title, + message }: ErrorFallbackProps) { const handleRetry = () => { if (resetError) { @@ -25,41 +25,52 @@ function ErrorFallback({ } }; + // 에러 메시지에서 데이터 없음 관련 키워드 확인 + const isNoDataError = error?.message?.includes('배치된 장비가 없거나') || + error?.message?.includes('데이터를 받을 수 없습니다'); + + const displayTitle = title || (isNoDataError ? '배치된 장비가 없습니다' : '오류가 발생했습니다'); + const displayMessage = message || error?.message || '일시적인 문제가 발생했습니다. 다시 시도해 주세요.'; + return ( -
- +
+ -

- {title} +

+ {displayTitle}

-

- {message} +

+ {displayMessage}

- {/* {import.meta.env.DEV && error && ( -
- + {import.meta.env.DEV && error && !isNoDataError && ( +
+ 상세 오류 정보 -
+
Error:
{error.toString()}
-
Stack Trace:
-
-              {error.stack}
-            
+ {error.stack && ( + <> +
Stack Trace:
+
+                  {error.stack}
+                
+ + )}
- )} */} + )} - 다시 시도
); } diff --git a/src/shared/error/index.ts b/src/shared/error/index.ts index b3ed12e..afaf3d4 100644 --- a/src/shared/error/index.ts +++ b/src/shared/error/index.ts @@ -1,2 +1,3 @@ export { default as ErrorBoundary } from './ErrorBoundary'; export { default as ErrorFallback } from './ErrorFallback'; +export { default as EmptyStateFallback } from './EmptyStateFallback'; diff --git a/src/shared/hooks/useDatacenterSSE.ts b/src/shared/hooks/useDatacenterSSE.ts index 0ee7ca9..dae97e6 100644 --- a/src/shared/hooks/useDatacenterSSE.ts +++ b/src/shared/hooks/useDatacenterSSE.ts @@ -57,6 +57,9 @@ export const useDatacenterSSE = ( const [temperatureHumidityHistory, setTemperatureHumidityHistory] = useState([]); const sseConnectionRef = useRef(null); + const timeoutRef = useRef(null); + const hasReceivedDataRef = useRef(false); + const connectionStartTimeRef = useRef(0); // 시계열 데이터 추가 헬퍼 함수 const addToHistory = useCallback(( @@ -76,6 +79,14 @@ export const useDatacenterSSE = ( // SSE 메시지 처리 const handleMessage = useCallback( (data: DatacenterMetrics) => { + hasReceivedDataRef.current = true; + + // 타임아웃 타이머 취소 (데이터를 받았으므로) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setMetrics(data); setError(null); @@ -150,10 +161,17 @@ export const useDatacenterSSE = ( sseConnectionRef.current.close(); sseConnectionRef.current = null; } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } setIsConnected(false); return; } + // 데이터 수신 플래그 리셋 + hasReceivedDataRef.current = false; + // SSE 연결 생성 sseConnectionRef.current = createDatacenterSSE(datacenterId, { onMessage: handleMessage, @@ -166,6 +184,20 @@ export const useDatacenterSSE = ( console.log(`Connected to datacenter ${datacenterId} SSE`); setIsConnected(true); setError(null); + + // 연결 시작 시간 기록 + connectionStartTimeRef.current = Date.now(); + + // SSE 연결 성공 후 5초 타임아웃 설정 (절대 시간 기반) + timeoutRef.current = setTimeout(() => { + const elapsed = Date.now() - connectionStartTimeRef.current; + console.log(`Timeout check: elapsed=${elapsed}ms, hasData=${hasReceivedDataRef.current}`); + + if (!hasReceivedDataRef.current) { + console.warn('데이터센터에 배치된 장비가 없거나 데이터를 받을 수 없습니다.'); + setError('데이터센터에 배치된 장비가 없거나 데이터를 받을 수 없습니다.'); + } + }, 5000); }, reconnectDelay: 3000, maxReconnectAttempts: 10, @@ -177,6 +209,10 @@ export const useDatacenterSSE = ( sseConnectionRef.current.close(); sseConnectionRef.current = null; } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } setIsConnected(false); }; }, [datacenterId, enabled, handleMessage]); diff --git a/src/shared/hooks/useRackSSE.ts b/src/shared/hooks/useRackSSE.ts index e4c64ce..2e15cd2 100644 --- a/src/shared/hooks/useRackSSE.ts +++ b/src/shared/hooks/useRackSSE.ts @@ -45,6 +45,9 @@ export const useRackSSE = ( const [temperatureHumidityHistory, setTemperatureHumidityHistory] = useState([]); const sseConnectionRef = useRef(null); + const timeoutRef = useRef(null); + const hasReceivedDataRef = useRef(false); + const connectionStartTimeRef = useRef(0); // SSE 메시지 처리 const handleMessage = useCallback((data: RackMetrics) => { @@ -54,6 +57,14 @@ export const useRackSSE = ( return; } + hasReceivedDataRef.current = true; + + // 타임아웃 타이머 취소 (데이터를 받았으므로) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setMetrics(data); setError(null); @@ -109,10 +120,17 @@ export const useRackSSE = ( sseConnectionRef.current.close(); sseConnectionRef.current = null; } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } setIsConnected(false); return; } + // 데이터 수신 플래그 리셋 + hasReceivedDataRef.current = false; + // SSE 연결 생성 sseConnectionRef.current = createRackSSE(rackId, { onMessage: handleMessage, @@ -125,6 +143,20 @@ export const useRackSSE = ( console.log(`Connected to rack ${rackId} SSE`); setIsConnected(true); setError(null); + + // 연결 시작 시간 기록 + connectionStartTimeRef.current = Date.now(); + + // SSE 연결 성공 후 5초 타임아웃 설정 (절대 시간 기반) + timeoutRef.current = setTimeout(() => { + const elapsed = Date.now() - connectionStartTimeRef.current; + console.log(`Timeout check: elapsed=${elapsed}ms, hasData=${hasReceivedDataRef.current}`); + + if (!hasReceivedDataRef.current) { + console.warn('랙에 배치된 장비가 없거나 데이터를 받을 수 없습니다.'); + setError('랙에 배치된 장비가 없거나 데이터를 받을 수 없습니다.'); + } + }, 5000); }, reconnectDelay: 3000, maxReconnectAttempts: 10, @@ -136,6 +168,10 @@ export const useRackSSE = ( sseConnectionRef.current.close(); sseConnectionRef.current = null; } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } setIsConnected(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/shared/hooks/useServerRoomSSE.ts b/src/shared/hooks/useServerRoomSSE.ts index ba2dbea..e89989c 100644 --- a/src/shared/hooks/useServerRoomSSE.ts +++ b/src/shared/hooks/useServerRoomSSE.ts @@ -54,6 +54,9 @@ export const useServerRoomSSE = ( const [temperatureHumidityHistory, setTemperatureHumidityHistory] = useState([]); const sseConnectionRef = useRef(null); + const timeoutRef = useRef(null); + const hasReceivedDataRef = useRef(false); + const connectionStartTimeRef = useRef(0); // 시계열 데이터 추가 헬퍼 함수 const addToHistory = useCallback(( @@ -73,6 +76,14 @@ export const useServerRoomSSE = ( // SSE 메시지 처리 const handleMessage = useCallback( (data: ServerRoomMetrics) => { + hasReceivedDataRef.current = true; + + // 타임아웃 타이머 취소 (데이터를 받았으므로) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setMetrics(data); setError(null); @@ -138,10 +149,17 @@ export const useServerRoomSSE = ( sseConnectionRef.current.close(); sseConnectionRef.current = null; } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } setIsConnected(false); return; } + // 데이터 수신 플래그 리셋 + hasReceivedDataRef.current = false; + // SSE 연결 생성 sseConnectionRef.current = createServerRoomSSE(serverRoomId, { onMessage: handleMessage, @@ -154,6 +172,20 @@ export const useServerRoomSSE = ( console.log(`Connected to serverRoom ${serverRoomId} SSE`); setIsConnected(true); setError(null); + + // 연결 시작 시간 기록 + connectionStartTimeRef.current = Date.now(); + + // SSE 연결 성공 후 5초 타임아웃 설정 (절대 시간 기반) + timeoutRef.current = setTimeout(() => { + const elapsed = Date.now() - connectionStartTimeRef.current; + console.log(`Timeout check: elapsed=${elapsed}ms, hasData=${hasReceivedDataRef.current}`); + + if (!hasReceivedDataRef.current) { + console.warn('서버실에 배치된 장비가 없거나 데이터를 받을 수 없습니다.'); + setError('서버실에 배치된 장비가 없거나 데이터를 받을 수 없습니다.'); + } + }, 5000); }, reconnectDelay: 3000, maxReconnectAttempts: 10, @@ -165,6 +197,10 @@ export const useServerRoomSSE = ( sseConnectionRef.current.close(); sseConnectionRef.current = null; } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } setIsConnected(false); }; }, [serverRoomId, enabled, handleMessage]); diff --git a/src/shared/layout/NotificationBell.tsx b/src/shared/layout/NotificationBell.tsx index 426facb..ad51de3 100644 --- a/src/shared/layout/NotificationBell.tsx +++ b/src/shared/layout/NotificationBell.tsx @@ -98,7 +98,7 @@ function NotificationBell() { {/* 알림 드롭다운 */} {isOpen && ( -
+
{alerts.length === 0 ? ( From b24599e4173cab373d349868d251d9e4d3ead258 Mon Sep 17 00:00:00 2001 From: KIM_DEAHO <102588838+DHowor1d@users.noreply.github.com> Date: Tue, 25 Nov 2025 09:45:50 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20sse=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/alertApi.ts | 11 ++++-- src/api/sseClient.ts | 14 +++++-- src/shared/layout/NotificationBell.tsx | 51 ++++++++++++++++++++------ 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/api/alertApi.ts b/src/api/alertApi.ts index 1beb0b2..5088fb0 100644 --- a/src/api/alertApi.ts +++ b/src/api/alertApi.ts @@ -48,18 +48,23 @@ export const alertApi = { return response.data; }, - // 알림 읽음 처리 (필요시 구현) + // 알림 읽음 처리 markAsRead: async (alertId: number): Promise => { await client.patch(`/alerts/${alertId}/read`); }, - // 모든 알림 읽음 처리 (필요시 구현) + // 모든 알림 읽음 처리 markAllAsRead: async (): Promise => { await client.patch("/alerts/read-all"); }, - // 알림 삭제 (필요시 구현) + // 알림 삭제 deleteAlert: async (alertId: number): Promise => { await client.delete(`/alerts/${alertId}`); }, + + // 모든 알림 삭제 + deleteAllAlerts: async (): Promise => { + await client.delete("/alerts/delete-all"); + }, }; diff --git a/src/api/sseClient.ts b/src/api/sseClient.ts index 8aabab9..49a339e 100644 --- a/src/api/sseClient.ts +++ b/src/api/sseClient.ts @@ -16,10 +16,7 @@ export interface SSEConnection { isConnected: () => boolean; } -/** - * SSE 연결을 생성하고 관리하는 유틸리티 함수 - * EventSource는 헤더를 지원하지 않으므로 fetch로 스트림을 처리 - */ + export const createSSEConnection = ( endpoint: string, options: SSEOptions @@ -219,3 +216,12 @@ export const createRackSSE = ( ) => { return createSSEConnection(`/monitoring/subscribe/rack/${rackId}`, options); }; + +/** + * 알림 SSE 연결 생성 + */ +export const createAlertSSE = ( + options: Omit, "onOpen"> & { onOpen?: () => void } +) => { + return createSSEConnection(`/alerts/subscribe`, options); +}; diff --git a/src/shared/layout/NotificationBell.tsx b/src/shared/layout/NotificationBell.tsx index 426facb..d1d5fda 100644 --- a/src/shared/layout/NotificationBell.tsx +++ b/src/shared/layout/NotificationBell.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { IoNotificationsOutline } from "react-icons/io5"; import { alertApi, type Alert } from "@/api/alertApi"; +import { createAlertSSE } from "@/api/sseClient"; function NotificationBell() { const [isOpen, setIsOpen] = useState(false); @@ -9,22 +10,39 @@ function NotificationBell() { const unreadCount = alerts.filter((alert) => !alert.isRead).length; - // 알림 데이터 가져오기 + // SSE 연결 및 초기 데이터 로드 useEffect(() => { - const fetchAlerts = async () => { + // 초기 알림 데이터 가져오기 + const fetchInitialAlerts = async () => { try { - const response = await alertApi.getAlerts({ page: 0, size: 10, days: 7 }); + const response = await alertApi.getAlerts({ page: 0, size: 20, days: 7 }); setAlerts(response.content); } catch (error) { - console.error("Failed to fetch alerts:", error); + console.error("Failed to fetch initial alerts:", error); } }; - fetchAlerts(); - - // 30초마다 알림 업데이트 - const interval = setInterval(fetchAlerts, 30000); - return () => clearInterval(interval); + 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(); + }; }, []); // 외부 클릭 감지 @@ -49,7 +67,15 @@ function NotificationBell() { const handleToggle = () => { setIsOpen(!isOpen); - // TODO: 드롭다운을 열 때 읽음 처리 API 호출 + }; + + const handleDeleteAll = async () => { + try { + await alertApi.deleteAllAlerts(); + setAlerts([]); + } catch (error) { + console.error("Failed to delete all alerts:", error); + } }; const formatTimestamp = (dateString: string) => { @@ -145,7 +171,10 @@ function NotificationBell() { {alerts.length > 0 && (
-
From d4c5d225155377c31c4a76c244c3303e7183e19d Mon Sep 17 00:00:00 2001 From: KIM_DEAHO <102588838+DHowor1d@users.noreply.github.com> Date: Tue, 25 Nov 2025 09:57:03 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=8B=A4=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/DashboardEmptyFallback.tsx | 6 +++--- src/shared/layout/NotificationBell.tsx | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/domains/mainDashboard/components/DashboardEmptyFallback.tsx b/src/domains/mainDashboard/components/DashboardEmptyFallback.tsx index 7d99011..760b9b1 100644 --- a/src/domains/mainDashboard/components/DashboardEmptyFallback.tsx +++ b/src/domains/mainDashboard/components/DashboardEmptyFallback.tsx @@ -32,13 +32,13 @@ function DashboardEmptyFallback({ error }: DashboardEmptyFallbackProps) { {title} -

+

{message}

-

- 💡 장비 배치는 서버실 뷰에서 할 수 있습니다 +

+ 장비 배치는 서버실 뷰에서 할 수 있습니다

diff --git a/src/shared/layout/NotificationBell.tsx b/src/shared/layout/NotificationBell.tsx index 4c66b0f..24744ba 100644 --- a/src/shared/layout/NotificationBell.tsx +++ b/src/shared/layout/NotificationBell.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } 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"; @@ -7,6 +8,7 @@ function NotificationBell() { const [isOpen, setIsOpen] = useState(false); const [alerts, setAlerts] = useState([]); const dropdownRef = useRef(null); + const navigate = useNavigate(); const unreadCount = alerts.filter((alert) => !alert.isRead).length; @@ -78,6 +80,14 @@ function NotificationBell() { } }; + const handleAlertClick = (alert: Alert) => { + // 서버룸으로 이동 + if (alert.serverRoomId) { + navigate(`/server-room/${alert.serverRoomId}/view`); + setIsOpen(false); + } + }; + const formatTimestamp = (dateString: string) => { const date = new Date(dateString); const now = new Date(); @@ -136,6 +146,7 @@ function NotificationBell() { {alerts.map((alert) => (
handleAlertClick(alert)} className={`px-4 py-3 hover:bg-gray-750 transition-colors cursor-pointer ${ !alert.isRead ? "bg-gray-750/50" : "" }`} From 08e90ad342bc8eac23df387004a6aa2fc86e624e Mon Sep 17 00:00:00 2001 From: KIM_DEAHO <102588838+DHowor1d@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:24:32 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EB=9E=99=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/MemberFilters.tsx | 30 ++++ .../components/memberTable.config.tsx | 11 +- .../humanResource/pages/HumanResource.tsx | 28 +++- .../serverRoom/components/DataCenterTabs.tsx | 10 +- .../serverRoom/pages/ServerRoomDashboard.tsx | 18 ++- .../components/BabylonDatacenterView.tsx | 11 ++ .../view3d/components/Equipment3DModel.tsx | 78 +++++++++- src/index.css | 38 ++++- src/shared/hooks/index.ts | 2 + src/shared/hooks/useServerRoomAlerts.ts | 133 ++++++++++++++++++ src/shared/table/components/DataTable.tsx | 8 +- .../table/components/TableHeaderCheckbox.tsx | 2 +- 12 files changed, 347 insertions(+), 22 deletions(-) create mode 100644 src/domains/humanResource/components/MemberFilters.tsx create mode 100644 src/shared/hooks/useServerRoomAlerts.ts diff --git a/src/domains/humanResource/components/MemberFilters.tsx b/src/domains/humanResource/components/MemberFilters.tsx new file mode 100644 index 0000000..9e3a686 --- /dev/null +++ b/src/domains/humanResource/components/MemberFilters.tsx @@ -0,0 +1,30 @@ +import { Search } from 'lucide-react'; + +interface MemberFiltersProps { + searchTerm: string; + onSearchChange: (value: string) => void; +} + +export default function MemberFilters({ + searchTerm, + onSearchChange, +}: MemberFiltersProps) { + return ( +
+ {/* 검색창 */} +
+ + onSearchChange(e.target.value)} + /> +
+
+ ); +} diff --git a/src/domains/humanResource/components/memberTable.config.tsx b/src/domains/humanResource/components/memberTable.config.tsx index 10a64f3..eb86f97 100644 --- a/src/domains/humanResource/components/memberTable.config.tsx +++ b/src/domains/humanResource/components/memberTable.config.tsx @@ -28,7 +28,7 @@ export const memberColumns: ColumnDef[] = [ cell: ({ row }) => ( [] = [ ), enableSorting: false, enableHiding: false, + size: 50, }, // ID @@ -71,6 +72,7 @@ export const memberColumns: ColumnDef[] = [ ); }, + size: 140, }, // 이름 @@ -87,6 +89,7 @@ export const memberColumns: ColumnDef[] = [ ); }, + size: 120, }, // 이메일 @@ -96,6 +99,7 @@ export const memberColumns: ColumnDef[] = [ cell: ({ getValue }) => { return {getValue()}; }, + size: 200, }, // 역할 @@ -124,6 +128,7 @@ export const memberColumns: ColumnDef[] = [ ); }, + size: 110, }, // 마지막 로그인 @@ -157,6 +162,7 @@ export const memberColumns: ColumnDef[] = [ ); }, + size: 160, }, // 관리 액션 @@ -164,7 +170,7 @@ export const memberColumns: ColumnDef[] = [ id: 'actions', header: '관리', cell: ({ row, table }) => ( -
+
), enableSorting: false, + size: 80, }, ]; diff --git a/src/domains/humanResource/pages/HumanResource.tsx b/src/domains/humanResource/pages/HumanResource.tsx index ed4eb97..ec4d37d 100644 --- a/src/domains/humanResource/pages/HumanResource.tsx +++ b/src/domains/humanResource/pages/HumanResource.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, + getFilteredRowModel, } from '@tanstack/react-table'; import type { PaginationState, @@ -14,6 +15,7 @@ import { Plus, Trash2 } from 'lucide-react'; import { DataTable, DataTablePagination } from '@/shared/table'; import { memberColumns } from '../components/memberTable.config'; +import MemberFilters from '../components/MemberFilters'; import AddMemberModal from '../components/AddMemberModal'; import EditMemberModal from '../components/EditMemberModal'; import DeleteMemberModal from '../components/DeleteMemberModal'; @@ -33,6 +35,7 @@ export default function HumanResource() { }); const [rowSelection, setRowSelection] = useState({}); const [sorting, setSorting] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [selectedMember, setSelectedMember] = useState(null); @@ -45,6 +48,20 @@ export default function HumanResource() { const deleteMemberMutation = useDeleteMember(); const deleteMultipleMembersMutation = useDeleteMultipleMembers(); + // 검색 필터링된 데이터 + const filteredData = useMemo(() => { + if (!searchTerm) return memberData; + + const lowerSearch = searchTerm.toLowerCase(); + return memberData.filter((member) => { + return ( + member.name.toLowerCase().includes(lowerSearch) || + member.userName.toLowerCase().includes(lowerSearch) || + member.email.toLowerCase().includes(lowerSearch) + ); + }); + }, [memberData, searchTerm]); + // --- 이벤트 핸들러 --- const handleAddMember = () => { setIsAddModalOpen(true); @@ -100,7 +117,7 @@ export default function HumanResource() { // --- 테이블 인스턴스 --- const table = useReactTable({ - data: memberData, + data: filteredData, columns: memberColumns, state: { pagination, rowSelection, sorting }, onPaginationChange: setPagination, @@ -109,6 +126,7 @@ export default function HumanResource() { getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), enableRowSelection: true, meta: { onEdit: handleEditMember, @@ -138,6 +156,12 @@ export default function HumanResource() { {/* 메인 컨텐츠 */}
+ {/* 검색 필터 */} + + {/* 대량 작업 버튼 */} {selectedCount > 0 && (
diff --git a/src/domains/serverRoom/components/DataCenterTabs.tsx b/src/domains/serverRoom/components/DataCenterTabs.tsx index 2808e9e..4aaa398 100644 --- a/src/domains/serverRoom/components/DataCenterTabs.tsx +++ b/src/domains/serverRoom/components/DataCenterTabs.tsx @@ -9,6 +9,7 @@ interface DataCenterTabsProps { onEditDataCenter: (dataCenter: DataCenterGroup) => void; onDeleteDataCenter: (dataCenter: DataCenterGroup) => void; onCreateDataCenter: () => void; + userRole?: string; } export function DataCenterTabs({ @@ -18,6 +19,7 @@ export function DataCenterTabs({ onEditDataCenter, onDeleteDataCenter, onCreateDataCenter, + userRole, }: DataCenterTabsProps) { return (
@@ -41,9 +43,11 @@ export function DataCenterTabs({ />
))} - + {userRole !== 'VIEWER' && ( + + )}
); } diff --git a/src/domains/serverRoom/pages/ServerRoomDashboard.tsx b/src/domains/serverRoom/pages/ServerRoomDashboard.tsx index 0fd2ba4..5788e8e 100644 --- a/src/domains/serverRoom/pages/ServerRoomDashboard.tsx +++ b/src/domains/serverRoom/pages/ServerRoomDashboard.tsx @@ -17,9 +17,10 @@ const ServerRoomDashboard: React.FC = () => { datacenterId ? parseInt(datacenterId, 10) : null ); - // 로그인한 사용자의 회사 ID 가져오기 + // 로그인한 사용자의 회사 ID 및 권한 가져오기 const { user } = useAuthStore(); const companyId = user?.companyId; + const userRole = user?.role; // 데이터 조회 const { @@ -137,12 +138,14 @@ const ServerRoomDashboard: React.FC = () => {
- + {userRole !== 'VIEWER' && ( + + )} {/* Data Center Tabs */} @@ -153,6 +156,7 @@ const ServerRoomDashboard: React.FC = () => { onEditDataCenter={dataCenterActions.openEditModal} onDeleteDataCenter={dataCenterActions.openDeleteModal} onCreateDataCenter={dataCenterActions.openCreateModal} + userRole={userRole} /> {/* Main Content */} diff --git a/src/domains/serverView/view3d/components/BabylonDatacenterView.tsx b/src/domains/serverView/view3d/components/BabylonDatacenterView.tsx index 34ff7cf..8954e59 100644 --- a/src/domains/serverView/view3d/components/BabylonDatacenterView.tsx +++ b/src/domains/serverView/view3d/components/BabylonDatacenterView.tsx @@ -19,6 +19,7 @@ import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { LoadingSpinner } from '@/shared/loading'; import { createDevice } from '../api/serverRoomEquipmentApi'; import { getNextDeviceNumber, generateDeviceName } from '../utils/deviceNameGenerator'; +import { useServerRoomAlerts } from '@/shared/hooks/useServerRoomAlerts'; import type { EquipmentType, Equipment3D } from '../../types'; interface BabylonDatacenterViewProps { @@ -71,6 +72,9 @@ function BabylonDatacenterView({ mode: initialMode = 'view', serverRoomId }: Bab const isEquipmentReady = !equipmentLoading && !equipmentFetching; + // 알림 상태 (서버실 ID로 필터링) + const { rackAlerts } = useServerRoomAlerts(serverRoomId ? Number(serverRoomId) : undefined); + // 커스텀 훅들 const { toast, showToast, hideToast } = useToast(); @@ -357,6 +361,12 @@ function BabylonDatacenterView({ mode: initialMode = 'view', serverRoomId }: Bab const paletteItem = EQUIPMENT_PALETTE.find((p) => p.type === eq.type); if (!paletteItem) return null; + const rackIdKey = eq.rackId ? Number(eq.rackId) : undefined; + const alertStatus = + rackIdKey !== undefined && !Number.isNaN(rackIdKey) + ? rackAlerts.get(rackIdKey) + : undefined; + return ( ); })} diff --git a/src/domains/serverView/view3d/components/Equipment3DModel.tsx b/src/domains/serverView/view3d/components/Equipment3DModel.tsx index b1f51da..812259c 100644 --- a/src/domains/serverView/view3d/components/Equipment3DModel.tsx +++ b/src/domains/serverView/view3d/components/Equipment3DModel.tsx @@ -39,6 +39,7 @@ interface Equipment3DModelProps { originalGridY: number; }[] ) => Promise; // Promise 반환으로 변경 + alertLevel?: 'CRITICAL' | 'WARNING'; // 알림 레벨 추가 } function Equipment3DModel({ @@ -54,6 +55,7 @@ function Equipment3DModel({ onRightClick, // 우클릭 핸들러 selectedEquipmentIds = [], onMultiDragEnd, + alertLevel, }: Equipment3DModelProps) { const meshRef = useRef(null); const dragBehaviorRef = useRef(null); @@ -617,6 +619,79 @@ function Equipment3DModel({ updateHighlight(meshRef.current); }, [isSelected, isLoaded]); + // 알림 깜박임 효과 + useEffect(() => { + if (!meshRef.current || !isLoaded || !alertLevel) return; + + const mesh = meshRef.current; + const childMeshes = mesh.getChildMeshes(); + + // 깜빡임 색상 설정 (선택 표시처럼 강한 하이라이트 색상) + const alertColor = alertLevel === 'CRITICAL' + ? Color3.FromHexString('#FF0000') // 빨간색 + : Color3.FromHexString('#FFD700'); // 금색/노란색 + + let blinkInterval: number | null = null; + let isBlinkOn = true; + + // 깜빡임 애니메이션 (0.5초 간격) + const updateBlink = (meshToUpdate: AbstractMesh) => { + if (meshToUpdate.material && 'emissiveColor' in meshToUpdate.material) { + const material = meshToUpdate.material as { emissiveColor: Color3 }; + + if (isBlinkOn) { + // 깜빡임 ON: 선택 표시처럼 강한 하이라이트 (0.3은 선택 표시와 동일) + material.emissiveColor = alertColor.scale(0.3); + } else { + // 깜박임 OFF: 원래 색상 또는 어두운 상태 + const originalColor = originalEmissiveColors.current.get( + meshToUpdate.uniqueId.toString() + ); + if (originalColor) { + material.emissiveColor = originalColor.clone(); + } else { + material.emissiveColor = new Color3(0.1, 0.1, 0.1); + } + } + } + }; + + blinkInterval = setInterval(() => { + isBlinkOn = !isBlinkOn; + childMeshes.forEach(updateBlink); + updateBlink(mesh); + }, 500) as unknown as number; + + // 초기 상태 설정 + childMeshes.forEach(updateBlink); + updateBlink(mesh); + + // 정리 + return () => { + if (blinkInterval !== null) { + clearInterval(blinkInterval); + } + + // 원래 색상으로 복원 + const restoreOriginalColor = (meshToRestore: AbstractMesh) => { + if (meshToRestore.material && 'emissiveColor' in meshToRestore.material) { + const material = meshToRestore.material as { emissiveColor: Color3 }; + const originalColor = originalEmissiveColors.current.get( + meshToRestore.uniqueId.toString() + ); + if (originalColor) { + material.emissiveColor = originalColor.clone(); + } else { + material.emissiveColor = new Color3(1, 1, 1); + } + } + }; + + childMeshes.forEach(restoreOriginalColor); + restoreOriginalColor(mesh); + }; + }, [alertLevel, isLoaded]); + return null; } @@ -639,7 +714,8 @@ const MemoizedEquipment3DModel = memo( prevProps.cellSize === nextProps.cellSize && prevProps.modelPath === nextProps.modelPath && prevProps.isSelected === nextProps.isSelected && - prevProps.isDraggable === nextProps.isDraggable; + prevProps.isDraggable === nextProps.isDraggable && + prevProps.alertLevel === nextProps.alertLevel; // selectedEquipmentIds는 이 장비가 선택되었는지 여부만 확인 const wasSelected = diff --git a/src/index.css b/src/index.css index 9dfbb11..5c8a5e3 100644 --- a/src/index.css +++ b/src/index.css @@ -1,13 +1,45 @@ @import "tailwindcss"; +/* 커스텀 스크롤바 스타일 */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.5) transparent; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: rgba(100, 116, 139, 0.5); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: rgba(148, 163, 184, 0.7); +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + /* 스크롤바 숨기기 */ @layer utilities { .scrollbar-hide { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none !important; /* IE and Edge */ + scrollbar-width: none !important; /* Firefox */ } .scrollbar-hide::-webkit-scrollbar { - display: none; /* Chrome, Safari and Opera */ + display: none !important; /* Chrome, Safari and Opera */ + width: 0 !important; + height: 0 !important; } } diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 7edbaa3..fc2b38a 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,6 +1,8 @@ export { useDatacenterSSE } from './useDatacenterSSE'; export { useServerRoomSSE } from './useServerRoomSSE'; export { useRackSSE } from './useRackSSE'; +export { useServerRoomAlerts } from './useServerRoomAlerts'; export type { UseDatacenterSSEResult } from './useDatacenterSSE'; export type { UseServerRoomSSEResult } from './useServerRoomSSE'; export type { UseRackSSEResult } from './useRackSSE'; +export type { RackAlert } from './useServerRoomAlerts'; diff --git a/src/shared/hooks/useServerRoomAlerts.ts b/src/shared/hooks/useServerRoomAlerts.ts new file mode 100644 index 0000000..9c4401a --- /dev/null +++ b/src/shared/hooks/useServerRoomAlerts.ts @@ -0,0 +1,133 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { createAlertSSE } from '@/api/sseClient'; +import { alertApi, type Alert } from '@/api/alertApi'; + +export interface RackAlert { + rackId: number; + level: 'CRITICAL' | 'WARNING'; + latestAlert: Alert; +} + +/** + * 서버실 알림을 실시간으로 수신하고 랙별 알림 상태를 관리하는 Hook + * @param serverRoomId 서버실 ID (없으면 전체 알림 수신) + */ +export const useServerRoomAlerts = (serverRoomId?: number) => { + const [rackAlerts, setRackAlerts] = useState>(new Map()); + const sseConnectionRef = useRef | null>(null); + const clearTimersRef = useRef([]); + + const shouldProcessAlert = useCallback( + (alert: Alert) => { + if (!alert.rackId) return false; + if (serverRoomId && alert.serverRoomId !== serverRoomId) return false; + return alert.targetType === 'RACK' || alert.targetType === 'EQUIPMENT'; + }, + [serverRoomId], + ); + + const upsertAlert = useCallback((map: Map, alert: Alert) => { + const rackId = alert.rackId!; + const level = (alert.level === 'CRITICAL' ? 'CRITICAL' : 'WARNING') as 'CRITICAL' | 'WARNING'; + const existing = map.get(rackId); + + // CRITICAL 우선 순위, WARNING은 동일 레벨만 갱신 + if (!existing || level === 'CRITICAL' || existing.level === 'WARNING') { + map.set(rackId, { + rackId, + level, + latestAlert: alert, + }); + } + + return map; + }, []); + + const scheduleAlertCleanup = useCallback((alert: Alert) => { + const timeoutId = window.setTimeout(() => { + setRackAlerts((prev) => { + const newMap = new Map(prev); + if (alert.rackId) { + const existing = newMap.get(alert.rackId); + if (existing && existing.latestAlert.alertId === alert.alertId) { + newMap.delete(alert.rackId); + } + } + return newMap; + }); + }, 300000); + + clearTimersRef.current.push(timeoutId); + }, []); + + const processAlert = useCallback( + (alert: Alert) => { + if (!shouldProcessAlert(alert)) return; + + setRackAlerts((prev) => { + const newMap = new Map(prev); + return upsertAlert(newMap, alert); + }); + + scheduleAlertCleanup(alert); + }, + [shouldProcessAlert, scheduleAlertCleanup, upsertAlert], + ); + + 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(); + + // SSE 연결 생성 + const connection = createAlertSSE({ + onMessage: (alert) => { + processAlert(alert); + }, + onError: (error) => { + console.error('Alert SSE error in useServerRoomAlerts:', error); + }, + onOpen: () => { + console.log('Alert SSE connection established in useServerRoomAlerts'); + }, + }); + + sseConnectionRef.current = connection; + + // 클린업 + return () => { + isMounted = false; + if (sseConnectionRef.current) { + sseConnectionRef.current.close(); + sseConnectionRef.current = null; + } + + clearTimersRef.current.forEach((timeoutId) => { + clearTimeout(timeoutId); + }); + clearTimersRef.current = []; + }; + }, [processAlert, scheduleAlertCleanup, shouldProcessAlert, upsertAlert]); + + return { rackAlerts }; +}; diff --git a/src/shared/table/components/DataTable.tsx b/src/shared/table/components/DataTable.tsx index 2a76cee..dbb4ac8 100644 --- a/src/shared/table/components/DataTable.tsx +++ b/src/shared/table/components/DataTable.tsx @@ -33,7 +33,7 @@ export default function DataTable({ return (
- +
{/* 테이블 헤더 */} {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( @@ -41,7 +41,8 @@ export default function DataTable({ {headerGroup.headers.map((header) => ( diff --git a/src/shared/table/components/TableHeaderCheckbox.tsx b/src/shared/table/components/TableHeaderCheckbox.tsx index d33a00f..035195c 100644 --- a/src/shared/table/components/TableHeaderCheckbox.tsx +++ b/src/shared/table/components/TableHeaderCheckbox.tsx @@ -27,7 +27,7 @@ export default function TableHeaderCheckbox({ From 30090669c94c5a262dafdf14a6f5472fe325de3b Mon Sep 17 00:00:00 2001 From: KIM_DEAHO <102588838+DHowor1d@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:40:46 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/mainDashboard/data/mockData.ts | 880 ++++++++++----------- 1 file changed, 440 insertions(+), 440 deletions(-) diff --git a/src/domains/mainDashboard/data/mockData.ts b/src/domains/mainDashboard/data/mockData.ts index 19051bb..7561400 100644 --- a/src/domains/mainDashboard/data/mockData.ts +++ b/src/domains/mainDashboard/data/mockData.ts @@ -1,495 +1,495 @@ -import type { Datacenter, Equipment, SystemMetric, NetworkMetric, StorageMetric, NetworkTrafficData } from '../types/dashboard.types'; +// import type { Datacenter, Equipment, SystemMetric, NetworkMetric, StorageMetric, NetworkTrafficData } from '../types/dashboard.types'; -// 하드코딩 메트릭 데이터 생성 헬퍼 -const generateSystemMetric = (equipmentId: number): SystemMetric => { - const cpuIdle = Math.random() * 40 + 60; // 60-100% - const memoryUsage = Math.random() * 50 + 30; // 30-80% +// // 하드코딩 메트릭 데이터 생성 헬퍼 +// const generateSystemMetric = (equipmentId: number): SystemMetric => { +// const cpuIdle = Math.random() * 40 + 60; // 60-100% +// const memoryUsage = Math.random() * 50 + 30; // 30-80% - return { - id: equipmentId, - equipment_id: equipmentId, - context_switches: Math.floor(Math.random() * 10000 + 2000), - cpu_idle: cpuIdle, - cpu_irq: Math.random() * 2, - cpu_nice: Math.random() * 1, - cpu_softirq: Math.random() * 2, - cpu_steal: Math.random() * 1, - cpu_system: Math.random() * 8 + 2, - cpu_user: Math.random() * 15 + 5, - cpu_wait: Math.random() * 4, - free_memory: Math.floor(Math.random() * 5000000000 + 5000000000), - generate_time: new Date().toISOString(), - load_avg1: Math.random() * 2 + 0.5, - load_avg15: Math.random() * 1.5 + 0.5, - load_avg5: Math.random() * 1.8 + 0.5, - memory_active: Math.floor(Math.random() * 5000000000), - memory_buffers: Math.floor(Math.random() * 1000000000), - memory_cached: Math.floor(Math.random() * 2000000000), - memory_inactive: Math.floor(Math.random() * 2500000000), - total_memory: 17179869184, - total_swap: 8589934592, - used_memory: Math.floor(17179869184 * (memoryUsage / 100)), - used_memory_percentage: memoryUsage, - used_swap: Math.floor(Math.random() * 500000000), - used_swap_percentage: Math.random() * 5, - }; -}; +// return { +// id: equipmentId, +// equipment_id: equipmentId, +// context_switches: Math.floor(Math.random() * 10000 + 2000), +// cpu_idle: cpuIdle, +// cpu_irq: Math.random() * 2, +// cpu_nice: Math.random() * 1, +// cpu_softirq: Math.random() * 2, +// cpu_steal: Math.random() * 1, +// cpu_system: Math.random() * 8 + 2, +// cpu_user: Math.random() * 15 + 5, +// cpu_wait: Math.random() * 4, +// free_memory: Math.floor(Math.random() * 5000000000 + 5000000000), +// generate_time: new Date().toISOString(), +// load_avg1: Math.random() * 2 + 0.5, +// load_avg15: Math.random() * 1.5 + 0.5, +// load_avg5: Math.random() * 1.8 + 0.5, +// memory_active: Math.floor(Math.random() * 5000000000), +// memory_buffers: Math.floor(Math.random() * 1000000000), +// memory_cached: Math.floor(Math.random() * 2000000000), +// memory_inactive: Math.floor(Math.random() * 2500000000), +// total_memory: 17179869184, +// total_swap: 8589934592, +// used_memory: Math.floor(17179869184 * (memoryUsage / 100)), +// used_memory_percentage: memoryUsage, +// used_swap: Math.floor(Math.random() * 500000000), +// used_swap_percentage: Math.random() * 5, +// }; +// }; -const generateNetworkMetric = (equipmentId: number, index: number): NetworkMetric => { - const rxUsage = Math.random() * 20 + 5; // 5-25% - const txUsage = Math.random() * 15 + 3; // 3-18% +// const generateNetworkMetric = (equipmentId: number, index: number): NetworkMetric => { +// const rxUsage = Math.random() * 20 + 5; // 5-25% +// const txUsage = Math.random() * 15 + 3; // 3-18% - return { - id: equipmentId * 10 + index, - equipment_id: equipmentId, - generate_time: new Date().toISOString(), - in_bytes_per_sec: Math.random() * 20000000 + 5000000, - in_bytes_tot: Math.floor(Math.random() * 300000000), - in_discard_pkts_tot: Math.floor(Math.random() * 3), - in_error_pkts_tot: Math.floor(Math.random() * 5), - in_pkts_per_sec: Math.random() * 15000 + 3000, - in_pkts_tot: Math.floor(Math.random() * 200000), - nic_name: `eth${index}`, - oper_status: 1, - out_bytes_per_sec: Math.random() * 15000000 + 3000000, - out_bytes_tot: Math.floor(Math.random() * 200000000), - out_discard_pkts_tot: Math.floor(Math.random() * 2), - out_error_pkts_tot: Math.floor(Math.random() * 4), - out_pkts_per_sec: Math.random() * 10000 + 2000, - out_pkts_tot: Math.floor(Math.random() * 150000), - rx_usage: rxUsage, - tx_usage: txUsage, - }; -}; +// return { +// id: equipmentId * 10 + index, +// equipment_id: equipmentId, +// generate_time: new Date().toISOString(), +// in_bytes_per_sec: Math.random() * 20000000 + 5000000, +// in_bytes_tot: Math.floor(Math.random() * 300000000), +// in_discard_pkts_tot: Math.floor(Math.random() * 3), +// in_error_pkts_tot: Math.floor(Math.random() * 5), +// in_pkts_per_sec: Math.random() * 15000 + 3000, +// in_pkts_tot: Math.floor(Math.random() * 200000), +// nic_name: `eth${index}`, +// oper_status: 1, +// out_bytes_per_sec: Math.random() * 15000000 + 3000000, +// out_bytes_tot: Math.floor(Math.random() * 200000000), +// out_discard_pkts_tot: Math.floor(Math.random() * 2), +// out_error_pkts_tot: Math.floor(Math.random() * 4), +// out_pkts_per_sec: Math.random() * 10000 + 2000, +// out_pkts_tot: Math.floor(Math.random() * 150000), +// rx_usage: rxUsage, +// tx_usage: txUsage, +// }; +// }; -const generateStorageMetric = (equipmentId: number): StorageMetric => { - const usedPercentage = Math.random() * 50 + 30; // 30-80% - const totalBytes = 536870912000; // 500GB - const usedBytes = Math.floor(totalBytes * (usedPercentage / 100)); +// const generateStorageMetric = (equipmentId: number): StorageMetric => { +// const usedPercentage = Math.random() * 50 + 30; // 30-80% +// const totalBytes = 536870912000; // 500GB +// const usedBytes = Math.floor(totalBytes * (usedPercentage / 100)); - return { - id: equipmentId, - equipment_id: equipmentId, - free_bytes: totalBytes - usedBytes, - free_inodes: Math.floor(Math.random() * 10000000 + 15000000), - generate_time: new Date().toISOString(), - io_read_bps: Math.random() * 15000000 + 3000000, - io_read_count: Math.floor(Math.random() * 50000 + 10000), - io_time_percentage: Math.random() * 30 + 5, - io_write_bps: Math.random() * 12000000 + 2000000, - io_write_count: Math.floor(Math.random() * 40000 + 8000), - total_bytes: totalBytes, - total_inodes: 32000000, - used_bytes: usedBytes, - used_inode_percentage: Math.random() * 40 + 15, - used_inodes: Math.floor(Math.random() * 15000000 + 5000000), - used_percentage: usedPercentage, - }; -}; +// return { +// id: equipmentId, +// equipment_id: equipmentId, +// free_bytes: totalBytes - usedBytes, +// free_inodes: Math.floor(Math.random() * 10000000 + 15000000), +// generate_time: new Date().toISOString(), +// io_read_bps: Math.random() * 15000000 + 3000000, +// io_read_count: Math.floor(Math.random() * 50000 + 10000), +// io_time_percentage: Math.random() * 30 + 5, +// io_write_bps: Math.random() * 12000000 + 2000000, +// io_write_count: Math.floor(Math.random() * 40000 + 8000), +// total_bytes: totalBytes, +// total_inodes: 32000000, +// used_bytes: usedBytes, +// used_inode_percentage: Math.random() * 40 + 15, +// used_inodes: Math.floor(Math.random() * 15000000 + 5000000), +// used_percentage: usedPercentage, +// }; +// }; -const getEquipmentStatus = (cpuUsage: number, memoryUsage: number, diskUsage: number) => { - if (cpuUsage > 90 || memoryUsage > 95 || diskUsage > 95) return 'critical'; - if (cpuUsage > 80 || memoryUsage > 85 || diskUsage > 85) return 'warning'; - return 'online'; -}; +// const getEquipmentStatus = (cpuUsage: number, memoryUsage: number, diskUsage: number) => { +// if (cpuUsage > 90 || memoryUsage > 95 || diskUsage > 95) return 'critical'; +// if (cpuUsage > 80 || memoryUsage > 85 || diskUsage > 85) return 'warning'; +// return 'online'; +// }; -// 장비 생성 -let equipmentIdCounter = 1; +// // 장비 생성 +// let equipmentIdCounter = 1; -const createEquipment = (rackId: number, positionU: number, type: Equipment['type']): Equipment => { - const id = equipmentIdCounter++; - const systemMetric = generateSystemMetric(id); - const networkMetrics = [generateNetworkMetric(id, 0), generateNetworkMetric(id, 1)]; - const storageMetric = generateStorageMetric(id); +// const createEquipment = (rackId: number, positionU: number, type: Equipment['type']): Equipment => { +// const id = equipmentIdCounter++; +// const systemMetric = generateSystemMetric(id); +// const networkMetrics = [generateNetworkMetric(id, 0), generateNetworkMetric(id, 1)]; +// const storageMetric = generateStorageMetric(id); - const cpuUsage = 100 - systemMetric.cpu_idle; - const memoryUsage = systemMetric.used_memory_percentage; - const diskUsage = storageMetric.used_percentage; +// const cpuUsage = 100 - systemMetric.cpu_idle; +// const memoryUsage = systemMetric.used_memory_percentage; +// const diskUsage = storageMetric.used_percentage; - return { - id, - name: `${type}-${String(id).padStart(3, '0')}`, - type, - rack_id: rackId, - position_u: positionU, - height_u: type === 'Server' ? 2 : 1, - ip_address: `192.168.${Math.floor(id / 254) + 1}.${id % 254 + 1}`, - status: getEquipmentStatus(cpuUsage, memoryUsage, diskUsage), - systemMetric, - networkMetrics, - storageMetric, - }; -}; +// return { +// id, +// name: `${type}-${String(id).padStart(3, '0')}`, +// type, +// rack_id: rackId, +// position_u: positionU, +// height_u: type === 'Server' ? 2 : 1, +// ip_address: `192.168.${Math.floor(id / 254) + 1}.${id % 254 + 1}`, +// status: getEquipmentStatus(cpuUsage, memoryUsage, diskUsage), +// systemMetric, +// networkMetrics, +// storageMetric, +// }; +// }; -// Mock 데이터 생성 -export const mockDatacenters: Datacenter[] = [ - { - id: 1, - name: '서울 데이터센터', - code: 'DC-SEOUL-01', - address: '서울특별시 강남구', - serverRooms: [ - { - id: 1, - name: '서버실 A', - code: 'SR-A-01', - location: '1층 동관', - floor: 1, - rows: 5, - columns: 10, - description: '주요 서버 라크', - status: 'ACTIVE', - racks: Array.from({ length: 5 }, (_, rackIdx) => { - let currentU = 0; - const equipments: Equipment[] = []; - const numEquipments = Math.floor(Math.random() * 6) + 5; // 5-10개 +// // Mock 데이터 생성 +// export const mockDatacenters: Datacenter[] = [ +// { +// id: 1, +// name: '서울 데이터센터', +// code: 'DC-SEOUL-01', +// address: '서울특별시 강남구', +// serverRooms: [ +// { +// id: 1, +// name: '서버실 A', +// code: 'SR-A-01', +// location: '1층 동관', +// floor: 1, +// rows: 5, +// columns: 10, +// description: '주요 서버 라크', +// status: 'ACTIVE', +// racks: Array.from({ length: 5 }, (_, rackIdx) => { +// let currentU = 0; +// const equipments: Equipment[] = []; +// const numEquipments = Math.floor(Math.random() * 6) + 5; // 5-10개 - for (let i = 0; i < numEquipments; i++) { - const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; - const type = types[Math.floor(Math.random() * types.length)]; - const equipment = createEquipment(rackIdx + 1, currentU, type); - currentU += equipment.height_u; - equipments.push(equipment); +// for (let i = 0; i < numEquipments; i++) { +// const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; +// const type = types[Math.floor(Math.random() * types.length)]; +// const equipment = createEquipment(rackIdx + 1, currentU, type); +// currentU += equipment.height_u; +// equipments.push(equipment); - if (currentU >= 40) break; // 42U 랙 제한 - } +// if (currentU >= 40) break; // 42U 랙 제한 +// } - return { - id: rackIdx + 1, - name: `Rack-A${String(rackIdx + 1).padStart(2, '0')}`, - deviceId: rackIdx + 1, - deviceCode: `RACK-A${String(rackIdx + 1).padStart(2, '0')}`, - gridY: 0, - gridX: rackIdx, - gridZ: 0, - rotation: 0, - status: 'ACTIVE', - equipments, - }; - }), - }, - { - id: 2, - name: '서버실 B', - code: 'SR-B-01', - location: '1층 서관', - floor: 1, - rows: 5, - columns: 10, - description: '보조 서버 라크', - status: 'ACTIVE', - racks: Array.from({ length: 5 }, (_, rackIdx) => { - let currentU = 0; - const equipments: Equipment[] = []; - const numEquipments = Math.floor(Math.random() * 6) + 5; +// return { +// id: rackIdx + 1, +// name: `Rack-A${String(rackIdx + 1).padStart(2, '0')}`, +// deviceId: rackIdx + 1, +// deviceCode: `RACK-A${String(rackIdx + 1).padStart(2, '0')}`, +// gridY: 0, +// gridX: rackIdx, +// gridZ: 0, +// rotation: 0, +// status: 'ACTIVE', +// equipments, +// }; +// }), +// }, +// { +// id: 2, +// name: '서버실 B', +// code: 'SR-B-01', +// location: '1층 서관', +// floor: 1, +// rows: 5, +// columns: 10, +// description: '보조 서버 라크', +// status: 'ACTIVE', +// racks: Array.from({ length: 5 }, (_, rackIdx) => { +// let currentU = 0; +// const equipments: Equipment[] = []; +// const numEquipments = Math.floor(Math.random() * 6) + 5; - for (let i = 0; i < numEquipments; i++) { - const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; - const type = types[Math.floor(Math.random() * types.length)]; - const equipment = createEquipment(rackIdx + 6, currentU, type); - currentU += equipment.height_u; - equipments.push(equipment); +// for (let i = 0; i < numEquipments; i++) { +// const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; +// const type = types[Math.floor(Math.random() * types.length)]; +// const equipment = createEquipment(rackIdx + 6, currentU, type); +// currentU += equipment.height_u; +// equipments.push(equipment); - if (currentU >= 40) break; - } +// if (currentU >= 40) break; +// } - return { - id: rackIdx + 6, - name: `Rack-B${String(rackIdx + 1).padStart(2, '0')}`, - deviceId: rackIdx + 6, - deviceCode: `RACK-B${String(rackIdx + 1).padStart(2, '0')}`, - gridY: 0, - gridX: rackIdx, - gridZ: 1, - rotation: 0, - status: 'ACTIVE', - equipments, - }; - }), - }, - { - id: 3, - name: '서버실 C', - code: 'SR-C-01', - location: '2층 동관', - floor: 2, - rows: 5, - columns: 10, - description: '스토리지 전용', - status: 'ACTIVE', - racks: Array.from({ length: 5 }, (_, rackIdx) => { - let currentU = 0; - const equipments: Equipment[] = []; - const numEquipments = Math.floor(Math.random() * 6) + 5; +// return { +// id: rackIdx + 6, +// name: `Rack-B${String(rackIdx + 1).padStart(2, '0')}`, +// deviceId: rackIdx + 6, +// deviceCode: `RACK-B${String(rackIdx + 1).padStart(2, '0')}`, +// gridY: 0, +// gridX: rackIdx, +// gridZ: 1, +// rotation: 0, +// status: 'ACTIVE', +// equipments, +// }; +// }), +// }, +// { +// id: 3, +// name: '서버실 C', +// code: 'SR-C-01', +// location: '2층 동관', +// floor: 2, +// rows: 5, +// columns: 10, +// description: '스토리지 전용', +// status: 'ACTIVE', +// racks: Array.from({ length: 5 }, (_, rackIdx) => { +// let currentU = 0; +// const equipments: Equipment[] = []; +// const numEquipments = Math.floor(Math.random() * 6) + 5; - for (let i = 0; i < numEquipments; i++) { - const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; - const type = types[Math.floor(Math.random() * types.length)]; - const equipment = createEquipment(rackIdx + 11, currentU, type); - currentU += equipment.height_u; - equipments.push(equipment); +// for (let i = 0; i < numEquipments; i++) { +// const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; +// const type = types[Math.floor(Math.random() * types.length)]; +// const equipment = createEquipment(rackIdx + 11, currentU, type); +// currentU += equipment.height_u; +// equipments.push(equipment); - if (currentU >= 40) break; - } +// if (currentU >= 40) break; +// } - return { - id: rackIdx + 11, - name: `Rack-C${String(rackIdx + 1).padStart(2, '0')}`, - deviceId: rackIdx + 11, - deviceCode: `RACK-C${String(rackIdx + 1).padStart(2, '0')}`, - gridY: 0, - gridX: rackIdx, - gridZ: 2, - rotation: 0, - status: 'ACTIVE', - equipments, - }; - }), - }, - ], - }, - { - id: 2, - name: '부산 데이터센터', - code: 'DC-BUSAN-01', - address: '부산광역시 해운대구', - serverRooms: [ - { - id: 4, - name: '서버실 A', - code: 'SR-BSN-A-01', - location: '1층', - floor: 1, - rows: 7, - columns: 12, - description: '메인 서버룸', - status: 'ACTIVE', - racks: Array.from({ length: 7 }, (_, rackIdx) => { - let currentU = 0; - const equipments: Equipment[] = []; - const numEquipments = Math.floor(Math.random() * 6) + 5; +// return { +// id: rackIdx + 11, +// name: `Rack-C${String(rackIdx + 1).padStart(2, '0')}`, +// deviceId: rackIdx + 11, +// deviceCode: `RACK-C${String(rackIdx + 1).padStart(2, '0')}`, +// gridY: 0, +// gridX: rackIdx, +// gridZ: 2, +// rotation: 0, +// status: 'ACTIVE', +// equipments, +// }; +// }), +// }, +// ], +// }, +// { +// id: 2, +// name: '부산 데이터센터', +// code: 'DC-BUSAN-01', +// address: '부산광역시 해운대구', +// serverRooms: [ +// { +// id: 4, +// name: '서버실 A', +// code: 'SR-BSN-A-01', +// location: '1층', +// floor: 1, +// rows: 7, +// columns: 12, +// description: '메인 서버룸', +// status: 'ACTIVE', +// racks: Array.from({ length: 7 }, (_, rackIdx) => { +// let currentU = 0; +// const equipments: Equipment[] = []; +// const numEquipments = Math.floor(Math.random() * 6) + 5; - for (let i = 0; i < numEquipments; i++) { - const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; - const type = types[Math.floor(Math.random() * types.length)]; - const equipment = createEquipment(rackIdx + 16, currentU, type); - currentU += equipment.height_u; - equipments.push(equipment); +// for (let i = 0; i < numEquipments; i++) { +// const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; +// const type = types[Math.floor(Math.random() * types.length)]; +// const equipment = createEquipment(rackIdx + 16, currentU, type); +// currentU += equipment.height_u; +// equipments.push(equipment); - if (currentU >= 40) break; - } +// if (currentU >= 40) break; +// } - return { - id: rackIdx + 16, - name: `Rack-A${String(rackIdx + 1).padStart(2, '0')}`, - deviceId: rackIdx + 16, - deviceCode: `RACK-BSN-A${String(rackIdx + 1).padStart(2, '0')}`, - gridY: 0, - gridX: rackIdx, - gridZ: 0, - rotation: 0, - status: 'ACTIVE', - equipments, - }; - }), - }, - { - id: 5, - name: '서버실 B', - code: 'SR-BSN-B-01', - location: '2층', - floor: 2, - rows: 6, - columns: 11, - description: '백업 서버룸', - status: 'ACTIVE', - racks: Array.from({ length: 6 }, (_, rackIdx) => { - let currentU = 0; - const equipments: Equipment[] = []; - const numEquipments = Math.floor(Math.random() * 6) + 5; +// return { +// id: rackIdx + 16, +// name: `Rack-A${String(rackIdx + 1).padStart(2, '0')}`, +// deviceId: rackIdx + 16, +// deviceCode: `RACK-BSN-A${String(rackIdx + 1).padStart(2, '0')}`, +// gridY: 0, +// gridX: rackIdx, +// gridZ: 0, +// rotation: 0, +// status: 'ACTIVE', +// equipments, +// }; +// }), +// }, +// { +// id: 5, +// name: '서버실 B', +// code: 'SR-BSN-B-01', +// location: '2층', +// floor: 2, +// rows: 6, +// columns: 11, +// description: '백업 서버룸', +// status: 'ACTIVE', +// racks: Array.from({ length: 6 }, (_, rackIdx) => { +// let currentU = 0; +// const equipments: Equipment[] = []; +// const numEquipments = Math.floor(Math.random() * 6) + 5; - for (let i = 0; i < numEquipments; i++) { - const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; - const type = types[Math.floor(Math.random() * types.length)]; - const equipment = createEquipment(rackIdx + 23, currentU, type); - currentU += equipment.height_u; - equipments.push(equipment); +// for (let i = 0; i < numEquipments; i++) { +// const types: Equipment['type'][] = ['Server', 'Switch', 'Storage', 'Router']; +// const type = types[Math.floor(Math.random() * types.length)]; +// const equipment = createEquipment(rackIdx + 23, currentU, type); +// currentU += equipment.height_u; +// equipments.push(equipment); - if (currentU >= 40) break; - } +// if (currentU >= 40) break; +// } - return { - id: rackIdx + 23, - name: `Rack-B${String(rackIdx + 1).padStart(2, '0')}`, - deviceId: rackIdx + 23, - deviceCode: `RACK-BSN-B${String(rackIdx + 1).padStart(2, '0')}`, - gridY: 0, - gridX: rackIdx, - gridZ: 1, - rotation: 0, - status: 'ACTIVE', - equipments, - }; - }), - }, - ], - }, -]; +// return { +// id: rackIdx + 23, +// name: `Rack-B${String(rackIdx + 1).padStart(2, '0')}`, +// deviceId: rackIdx + 23, +// deviceCode: `RACK-BSN-B${String(rackIdx + 1).padStart(2, '0')}`, +// gridY: 0, +// gridX: rackIdx, +// gridZ: 1, +// rotation: 0, +// status: 'ACTIVE', +// equipments, +// }; +// }), +// }, +// ], +// }, +// ]; -// 네트워크 트래픽 시계열 목 데이터 (최근 5분간, 15초 간격) -const generateNetworkTrafficData = (): NetworkTrafficData => { - const now = new Date(); - const trend: NetworkTrafficData['networkUsageTrend'] = []; +// // 네트워크 트래픽 시계열 목 데이터 (최근 5분간, 15초 간격) +// const generateNetworkTrafficData = (): NetworkTrafficData => { +// const now = new Date(); +// const trend: NetworkTrafficData['networkUsageTrend'] = []; - // 5분 = 300초, 15초 간격 = 20개 데이터 포인트 - for (let i = 19; i >= 0; i--) { - const timestamp = new Date(now.getTime() - i * 15 * 1000); +// // 5분 = 300초, 15초 간격 = 20개 데이터 포인트 +// for (let i = 19; i >= 0; i--) { +// const timestamp = new Date(now.getTime() - i * 15 * 1000); - // 실제 API 응답처럼 변동성 있는 데이터 생성 - // 기본 베이스 값에 랜덤 변동 추가 - const baseRx = 3.6e10; // 약 36 Gbps - const baseTx = 6.8e10; // 약 68 Gbps +// // 실제 API 응답처럼 변동성 있는 데이터 생성 +// // 기본 베이스 값에 랜덤 변동 추가 +// const baseRx = 3.6e10; // 약 36 Gbps +// const baseTx = 6.8e10; // 약 68 Gbps - // 주기적인 패턴과 노이즈 추가 - const variation = Math.sin(i * 0.5) * 0.1 + (Math.random() - 0.5) * 0.05; +// // 주기적인 패턴과 노이즈 추가 +// const variation = Math.sin(i * 0.5) * 0.1 + (Math.random() - 0.5) * 0.05; - trend.push({ - time: timestamp.toISOString(), - rxBytesPerSec: baseRx * (1 + variation), - txBytesPerSec: baseTx * (1 + variation), - }); - } +// trend.push({ +// time: timestamp.toISOString(), +// rxBytesPerSec: baseRx * (1 + variation), +// txBytesPerSec: baseTx * (1 + variation), +// }); +// } - // 현재 값은 마지막 트렌드 데이터 - const lastTrend = trend[trend.length - 1]; +// // 현재 값은 마지막 트렌드 데이터 +// const lastTrend = trend[trend.length - 1]; - return { - currentRxBytesPerSec: lastTrend.rxBytesPerSec, - currentTxBytesPerSec: lastTrend.txBytesPerSec, - networkUsageTrend: trend, - }; -}; +// return { +// currentRxBytesPerSec: lastTrend.rxBytesPerSec, +// currentTxBytesPerSec: lastTrend.txBytesPerSec, +// networkUsageTrend: trend, +// }; +// }; -// Load Average 시계열 목 데이터 -const generateLoadAverageData = () => { - const now = new Date(); - const data = []; +// // Load Average 시계열 목 데이터 +// const generateLoadAverageData = () => { +// const now = new Date(); +// const data = []; - // 최근 10분간 데이터 (30초 간격, 20개 포인트) - for (let i = 19; i >= 0; i--) { - const timestamp = new Date(now.getTime() - i * 30 * 1000); - const variation = Math.sin(i * 0.3) * 0.3 + (Math.random() - 0.5) * 0.2; +// // 최근 10분간 데이터 (30초 간격, 20개 포인트) +// for (let i = 19; i >= 0; i--) { +// const timestamp = new Date(now.getTime() - i * 30 * 1000); +// const variation = Math.sin(i * 0.3) * 0.3 + (Math.random() - 0.5) * 0.2; - data.push({ - time: timestamp.toISOString(), - loadAvg1: Math.max(0.1, 1.5 + variation), - loadAvg5: Math.max(0.1, 1.4 + variation * 0.8), - loadAvg15: Math.max(0.1, 1.3 + variation * 0.6), - }); - } +// data.push({ +// time: timestamp.toISOString(), +// loadAvg1: Math.max(0.1, 1.5 + variation), +// loadAvg5: Math.max(0.1, 1.4 + variation * 0.8), +// loadAvg15: Math.max(0.1, 1.3 + variation * 0.6), +// }); +// } - return data; -}; +// return data; +// }; -// Disk I/O 시계열 목 데이터 -const generateDiskIOData = () => { - const now = new Date(); - const data = []; +// // Disk I/O 시계열 목 데이터 +// const generateDiskIOData = () => { +// const now = new Date(); +// const data = []; - // 최근 5분간 데이터 (15초 간격, 20개 포인트) - for (let i = 19; i >= 0; i--) { - const timestamp = new Date(now.getTime() - i * 15 * 1000); - const variation = Math.sin(i * 0.4) * 0.3 + (Math.random() - 0.5) * 0.2; +// // 최근 5분간 데이터 (15초 간격, 20개 포인트) +// for (let i = 19; i >= 0; i--) { +// const timestamp = new Date(now.getTime() - i * 15 * 1000); +// const variation = Math.sin(i * 0.4) * 0.3 + (Math.random() - 0.5) * 0.2; - const baseRead = 5 * 1024 * 1024; // 5 MB/s - const baseWrite = 3 * 1024 * 1024; // 3 MB/s +// const baseRead = 5 * 1024 * 1024; // 5 MB/s +// const baseWrite = 3 * 1024 * 1024; // 3 MB/s - data.push({ - time: timestamp.toISOString(), - ioReadBps: Math.max(0, baseRead * (1 + variation)), - ioWriteBps: Math.max(0, baseWrite * (1 + variation * 0.8)), - ioTimePercentage: Math.max(5, Math.min(50, 20 + variation * 15)), - }); - } +// data.push({ +// time: timestamp.toISOString(), +// ioReadBps: Math.max(0, baseRead * (1 + variation)), +// ioWriteBps: Math.max(0, baseWrite * (1 + variation * 0.8)), +// ioTimePercentage: Math.max(5, Math.min(50, 20 + variation * 15)), +// }); +// } - return data; -}; +// return data; +// }; -// Context Switches 시계열 목 데이터 -const generateContextSwitchesData = () => { - const now = new Date(); - const data = []; +// // Context Switches 시계열 목 데이터 +// const generateContextSwitchesData = () => { +// const now = new Date(); +// const data = []; - // 최근 10분간 데이터 (30초 간격, 20개 포인트) - for (let i = 19; i >= 0; i--) { - const timestamp = new Date(now.getTime() - i * 30 * 1000); - const variation = Math.sin(i * 0.3) * 0.2 + (Math.random() - 0.5) * 0.1; +// // 최근 10분간 데이터 (30초 간격, 20개 포인트) +// for (let i = 19; i >= 0; i--) { +// const timestamp = new Date(now.getTime() - i * 30 * 1000); +// const variation = Math.sin(i * 0.3) * 0.2 + (Math.random() - 0.5) * 0.1; - const base = 8500; // 평균 8500 context switches +// const base = 8500; // 평균 8500 context switches - data.push({ - time: timestamp.toISOString(), - contextSwitches: Math.max(5000, Math.floor(base * (1 + variation))), - }); - } +// data.push({ +// time: timestamp.toISOString(), +// contextSwitches: Math.max(5000, Math.floor(base * (1 + variation))), +// }); +// } - return data; -}; +// return data; +// }; -// 네트워크 에러/드롭 통계 목 데이터 -const generateNetworkErrorData = () => { - return [ - { - nicName: 'eth0', - errorRate: 0.0052, // 0.0052% - dropRate: 0.0148, // 0.0148% - }, - { - nicName: 'eth1', - errorRate: 0.0054, - dropRate: 0.0152, - }, - ]; -}; +// // 네트워크 에러/드롭 통계 목 데이터 +// const generateNetworkErrorData = () => { +// return [ +// { +// nicName: 'eth0', +// errorRate: 0.0052, // 0.0052% +// dropRate: 0.0148, // 0.0148% +// }, +// { +// nicName: 'eth1', +// errorRate: 0.0054, +// dropRate: 0.0152, +// }, +// ]; +// }; -// CPU 상세 사용률 시계열 목 데이터 -const generateCpuUsageDetailData = () => { - const now = new Date(); - const data = []; +// // CPU 상세 사용률 시계열 목 데이터 +// const generateCpuUsageDetailData = () => { +// const now = new Date(); +// const data = []; - // 최근 5분간 데이터 (15초 간격, 20개 포인트) - for (let i = 19; i >= 0; i--) { - const timestamp = new Date(now.getTime() - i * 15 * 1000); - const variation = Math.sin(i * 0.5) * 0.1 + (Math.random() - 0.5) * 0.05; +// // 최근 5분간 데이터 (15초 간격, 20개 포인트) +// for (let i = 19; i >= 0; i--) { +// const timestamp = new Date(now.getTime() - i * 15 * 1000); +// const variation = Math.sin(i * 0.5) * 0.1 + (Math.random() - 0.5) * 0.05; - const cpuIdle = Math.max(60, Math.min(80, 70 + variation * 10)); - const cpuUser = Math.max(10, Math.min(25, 18 + variation * 5)); - const cpuSystem = Math.max(5, Math.min(12, 7.5 + variation * 3)); - const cpuWait = Math.max(1, Math.min(5, 2.4 + variation * 2)); - const cpuNice = Math.random() * 1; - const cpuIrq = Math.random() * 0.7; - const cpuSoftirq = Math.random() * 0.5; - const cpuSteal = Math.random() * 0.2; +// const cpuIdle = Math.max(60, Math.min(80, 70 + variation * 10)); +// const cpuUser = Math.max(10, Math.min(25, 18 + variation * 5)); +// const cpuSystem = Math.max(5, Math.min(12, 7.5 + variation * 3)); +// const cpuWait = Math.max(1, Math.min(5, 2.4 + variation * 2)); +// const cpuNice = Math.random() * 1; +// const cpuIrq = Math.random() * 0.7; +// const cpuSoftirq = Math.random() * 0.5; +// const cpuSteal = Math.random() * 0.2; - data.push({ - time: timestamp.toISOString(), - cpuUser, - cpuSystem, - cpuWait, - cpuNice, - cpuIrq, - cpuSoftirq, - cpuSteal, - cpuIdle, - }); - } +// data.push({ +// time: timestamp.toISOString(), +// cpuUser, +// cpuSystem, +// cpuWait, +// cpuNice, +// cpuIrq, +// cpuSoftirq, +// cpuSteal, +// cpuIdle, +// }); +// } - return data; -}; +// return data; +// }; -export const mockNetworkTrafficData = generateNetworkTrafficData(); -export const mockLoadAverageData = generateLoadAverageData(); -export const mockDiskIOData = generateDiskIOData(); -export const mockContextSwitchesData = generateContextSwitchesData(); -export const mockNetworkErrorData = generateNetworkErrorData(); -export const mockCpuUsageDetailData = generateCpuUsageDetailData(); +// export const mockNetworkTrafficData = generateNetworkTrafficData(); +// export const mockLoadAverageData = generateLoadAverageData(); +// export const mockDiskIOData = generateDiskIOData(); +// export const mockContextSwitchesData = generateContextSwitchesData(); +// export const mockNetworkErrorData = generateNetworkErrorData(); +// export const mockCpuUsageDetailData = generateCpuUsageDetailData();
{header.isPlaceholder ? null @@ -77,7 +78,8 @@ export default function DataTable({ {row.getVisibleCells().map((cell: Cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())}