Skip to content
Merged

Dev #185

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/api/alertApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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<AlertsResponse> => {
const { page = 0, size = 10, days = 7 } = params;
const response = await client.get<AlertsResponse>("/alerts", {
params: { page, size, days },
});
return response.data;
},

// 알림 읽음 처리
markAsRead: async (alertId: number): Promise<void> => {
await client.patch(`/alerts/${alertId}/read`);
},

// 모든 알림 읽음 처리
markAllAsRead: async (): Promise<void> => {
await client.patch("/alerts/read-all");
},

// 알림 삭제
deleteAlert: async (alertId: number): Promise<void> => {
await client.delete(`/alerts/${alertId}`);
},

// 모든 알림 삭제
deleteAllAlerts: async (): Promise<void> => {
await client.delete("/alerts/delete-all");
},
};
14 changes: 10 additions & 4 deletions src/api/sseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,7 @@ export interface SSEConnection {
isConnected: () => boolean;
}

/**
* SSE 연결을 생성하고 관리하는 유틸리티 함수
* EventSource는 헤더를 지원하지 않으므로 fetch로 스트림을 처리
*/

export const createSSEConnection = <T = unknown>(
endpoint: string,
options: SSEOptions<T>
Expand Down Expand Up @@ -219,3 +216,12 @@ export const createRackSSE = <T = unknown>(
) => {
return createSSEConnection(`/monitoring/subscribe/rack/${rackId}`, options);
};

/**
* 알림 SSE 연결 생성
*/
export const createAlertSSE = <T = unknown>(
options: Omit<SSEOptions<T>, "onOpen"> & { onOpen?: () => void }
) => {
return createSSEConnection(`/alerts/subscribe`, options);
};
30 changes: 30 additions & 0 deletions src/domains/humanResource/components/MemberFilters.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mb-6">
{/* 검색창 */}
<div className="relative w-full border border-slate-300/40 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 bg-gray-700/50 text-gray-50 placeholder-gray-400">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
size={20}
/>
<input
type="text"
placeholder="이름, 사용자ID, 이메일로 검색..."
className="w-full pl-10 pr-4 py-2 bg-transparent"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
</div>
);
}
11 changes: 9 additions & 2 deletions src/domains/humanResource/components/memberTable.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ export const memberColumns: ColumnDef<Member>[] = [
cell: ({ row }) => (
<input
type="checkbox"
className="rounded border-gray-600 bg-gray-700 focus:ring-slate-300/40"
className="w-5 h-5 rounded border-gray-600 bg-gray-700 focus:ring-slate-300/40"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
enableSorting: false,
enableHiding: false,
size: 50,
},

// ID
Expand Down Expand Up @@ -71,6 +72,7 @@ export const memberColumns: ColumnDef<Member>[] = [
</button>
);
},
size: 140,
},

// 이름
Expand All @@ -87,6 +89,7 @@ export const memberColumns: ColumnDef<Member>[] = [
</button>
);
},
size: 120,
},

// 이메일
Expand All @@ -96,6 +99,7 @@ export const memberColumns: ColumnDef<Member>[] = [
cell: ({ getValue }) => {
return <span>{getValue<string>()}</span>;
},
size: 200,
},

// 역할
Expand Down Expand Up @@ -124,6 +128,7 @@ export const memberColumns: ColumnDef<Member>[] = [
</span>
);
},
size: 110,
},

// 마지막 로그인
Expand Down Expand Up @@ -157,14 +162,15 @@ export const memberColumns: ColumnDef<Member>[] = [
</span>
);
},
size: 160,
},

// 관리 액션
{
id: 'actions',
header: '관리',
cell: ({ row, table }) => (
<div className="flex gap-2">
<div className="flex gap-2 justify-start">
<button
className="text-gray-400 hover:text-blue-400"
onClick={() =>
Expand All @@ -186,5 +192,6 @@ export const memberColumns: ColumnDef<Member>[] = [
</div>
),
enableSorting: false,
size: 80,
},
];
28 changes: 26 additions & 2 deletions src/domains/humanResource/pages/HumanResource.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand All @@ -33,6 +35,7 @@ export default function HumanResource() {
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [sorting, setSorting] = useState<SortingState>([]);
const [searchTerm, setSearchTerm] = useState('');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<Member | null>(null);
Expand All @@ -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);
Expand Down Expand Up @@ -100,7 +117,7 @@ export default function HumanResource() {

// --- 테이블 인스턴스 ---
const table = useReactTable({
data: memberData,
data: filteredData,
columns: memberColumns,
state: { pagination, rowSelection, sorting },
onPaginationChange: setPagination,
Expand All @@ -109,6 +126,7 @@ export default function HumanResource() {
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true,
meta: {
onEdit: handleEditMember,
Expand Down Expand Up @@ -138,6 +156,12 @@ export default function HumanResource() {

{/* 메인 컨텐츠 */}
<main className="flex-1 overflow-y-auto p-8">
{/* 검색 필터 */}
<MemberFilters
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>

{/* 대량 작업 버튼 */}
{selectedCount > 0 && (
<div className="mb-4 flex items-center gap-2">
Expand Down
4 changes: 4 additions & 0 deletions src/domains/mainDashboard/api/mainDashboardApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface Device {
status: string;
rackName: string | null;
rackId: number | null;
equipmentCount: number;
}

// 서버실 정보
Expand Down Expand Up @@ -47,6 +48,7 @@ export interface Rack {
gridZ: number;
rotation: number;
status: string;
equipmentCount: number; // 랙 내 장비 개수
equipments: []; // 빈 배열로 초기화 (필요시 추가 API로 장비 정보 로드)
}

Expand Down Expand Up @@ -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) => ({
Expand All @@ -133,6 +136,7 @@ export const getServerRoomRacks = async (
gridZ: device.gridZ,
rotation: device.rotation,
status: device.status,
equipmentCount: device.equipmentCount, // API 응답의 equipmentCount 사용
equipments: [], // 빈 배열로 초기화
}));
};
6 changes: 3 additions & 3 deletions src/domains/mainDashboard/components/CpuUsageChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", // 하늘색
},
Expand Down
48 changes: 48 additions & 0 deletions src/domains/mainDashboard/components/DashboardEmptyFallback.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col items-center justify-center p-12 h-full min-h-full">
<PackageOpen className="w-20 h-20 text-gray-600 mb-6" strokeWidth={1.5} />

<h2 className="text-2xl font-semibold text-gray-300 mb-3">
{title}
</h2>

<p className="text-gray-200 text-center max-w-lg leading-relaxed">
{message}
</p>

<div className="mt-8 p-4 bg-neutral-800 rounded-lg border border-neutral-700">
<p className="text-base text-gray-200 text-center font-medium">
장비 배치는 <span className="text-green-600">서버실 뷰</span>에서 할 수 있습니다
</p>
</div>
</div>
);
}

export default DashboardEmptyFallback;
5 changes: 5 additions & 0 deletions src/domains/mainDashboard/components/DatacenterDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export default function DatacenterDashboard({
// reconnect,
} = useDatacenterSSE(datacenterId, true);

// 에러 발생 시 throw하여 ErrorBoundary가 처리하도록 함
if (error) {
throw new Error(error);
}

// 로딩 상태
if (!metrics) {
return (
Expand Down
Loading
Loading