Commit fcfb9aec authored by Sensor MVP Team's avatar Sensor MVP Team
Browse files

initial draft

parent 704ef42a
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'; connect-src 'self' ws: wss: http: https:;" always;
}
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "sensor-dashboard",
"version": "1.0.0",
"description": "Sensor Data Dashboard",
"private": true,
"proxy": "http://sensor.geumdo.net",
"dependencies": {
"@tailwindcss/forms": "^0.5.0",
"@types/node": "^16.18.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"axios": "^1.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.0",
"react-scripts": "5.0.1",
"recharts": "^3.1.0",
"tailwindcss": "^3.2.0",
"typescript": "^4.9.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:e2e": "cypress run",
"test:e2e:open": "cypress open",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^27.5.0",
"cypress": "^12.13.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.7.0",
"ts-jest": "^29.4.0"
}
}
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<!-- 개발 환경에서 CSP 완화 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self' http: https: data: blob: 'unsafe-inline'; connect-src 'self' ws: wss: http: https:;" />
<meta
name="description"
content="센서 데이터 수집 시스템 대시보드"
/>
<title>센서 데이터 대시보드</title>
</head>
<body>
<noscript>이 앱을 실행하려면 JavaScript를 활성화해야 합니다.</noscript>
<div id="root"></div>
</body>
</html>
\ No newline at end of file
.App {
text-align: left;
min-height: 100vh;
background-color: #f8fafc;
}
.App-header {
background-color: #282c34;
padding: 20px;
color: white;
}
.App-link {
color: #61dafb;
}
\ No newline at end of file
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Dashboard from './pages/Dashboard';
import History from './pages/History';
import Devices from './pages/Devices';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import ErrorBoundary from './components/ErrorBoundary';
import { ThemeProvider } from './contexts/ThemeContext';
import './App.css';
function App() {
return (
<ErrorBoundary>
<ThemeProvider>
<Router>
<div className="App bg-gray-50 dark:bg-gray-900 min-h-screen">
<Header />
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/history" element={<History />} />
<Route path="/devices" element={<Devices />} />
</Routes>
</main>
</div>
</div>
</Router>
</ThemeProvider>
</ErrorBoundary>
);
}
export default App;
\ No newline at end of file
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Dashboard from '../pages/Dashboard';
import { useDevices } from '../hooks/useDevices';
import { useSensorData } from '../hooks/useSensorData';
import { getDevices, getLatestData } from '../services/api';
// Mock the hooks and API services
jest.mock('../hooks/useDevices');
jest.mock('../hooks/useSensorData');
jest.mock('../services/api');
const mockUseDevices = useDevices as jest.MockedFunction<typeof useDevices>;
const mockUseSensorData = useSensorData as jest.MockedFunction<typeof useSensorData>;
const mockGetDevices = getDevices as jest.MockedFunction<typeof getDevices>;
const mockGetLatestData = getLatestData as jest.MockedFunction<typeof getLatestData>;
// Mock Recharts components
jest.mock('recharts', () => ({
LineChart: ({ children, data }: any) => (
<div data-testid="line-chart" data-chart-data={JSON.stringify(data)}>
{children}
</div>
),
Line: ({ dataKey }: any) => (
<div data-testid={`line-${dataKey}`}>Line: {dataKey}</div>
),
XAxis: () => <div data-testid="x-axis">XAxis</div>,
YAxis: () => <div data-testid="y-axis">YAxis</div>,
CartesianGrid: () => <div data-testid="cartesian-grid">CartesianGrid</div>,
Tooltip: () => <div data-testid="tooltip">Tooltip</div>,
Legend: () => <div data-testid="legend">Legend</div>,
ResponsiveContainer: ({ children }: any) => (
<div data-testid="responsive-container">{children}</div>
),
}));
const renderWithRouter = (component: React.ReactElement) => {
return render(
<BrowserRouter>
{component}
</BrowserRouter>
);
};
describe('API 오류 시나리오 테스트', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('네트워크 오류 시나리오', () => {
it('네트워크 연결 오류 시 적절한 오류 메시지를 표시한다', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '네트워크 연결을 확인해주세요.',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('네트워크 연결을 확인해주세요.')).toBeInTheDocument();
expect(screen.getByText('다시 시도')).toBeInTheDocument();
});
it('서버 연결 타임아웃 시 적절한 오류 메시지를 표시한다', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '서버 응답 시간이 초과되었습니다.',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('서버 응답 시간이 초과되었습니다.')).toBeInTheDocument();
});
it('센서 데이터 로딩 실패 시 적절한 오류 메시지를 표시한다', () => {
mockUseDevices.mockReturnValue({
devices: [{ id: 1, device_id: 'sensor-001', name: '테스트 센서', status: 'active', last_seen: '2025-08-01T10:00:00Z' }],
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: '센서 데이터를 불러올 수 없습니다.',
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('센서 데이터를 불러올 수 없습니다.')).toBeInTheDocument();
});
});
describe('서버 오류 시나리오', () => {
it('500 서버 오류 시 적절한 오류 메시지를 표시한다', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '서버 내부 오류가 발생했습니다. (500)',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('서버 내부 오류가 발생했습니다. (500)')).toBeInTheDocument();
});
it('404 리소스 없음 오류 시 적절한 오류 메시지를 표시한다', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '요청한 리소스를 찾을 수 없습니다. (404)',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('요청한 리소스를 찾을 수 없습니다. (404)')).toBeInTheDocument();
});
it('403 권한 없음 오류 시 적절한 오류 메시지를 표시한다', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '접근 권한이 없습니다. (403)',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('접근 권한이 없습니다. (403)')).toBeInTheDocument();
});
});
describe('데이터 무결성 오류 시나리오', () => {
it('잘못된 JSON 응답 시 적절한 오류 메시지를 표시한다', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '서버에서 잘못된 데이터 형식을 반환했습니다.',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('서버에서 잘못된 데이터 형식을 반환했습니다.')).toBeInTheDocument();
});
it('필수 필드 누락 시 적절한 오류 메시지를 표시한다', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '응답 데이터에 필수 필드가 누락되었습니다.',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('응답 데이터에 필수 필드가 누락되었습니다.')).toBeInTheDocument();
});
});
describe('재시도 기능 테스트', () => {
it('다시 시도 버튼 클릭 시 refetch 함수가 호출된다', async () => {
const mockRefetchDevices = jest.fn();
const mockRefetchData = jest.fn();
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '디바이스 목록을 불러올 수 없습니다.',
refetch: mockRefetchDevices
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: '센서 데이터를 불러올 수 없습니다.',
refetch: mockRefetchData
});
renderWithRouter(<Dashboard />);
const retryButton = screen.getByText('다시 시도');
fireEvent.click(retryButton);
expect(mockRefetchDevices).toHaveBeenCalled();
expect(mockRefetchData).toHaveBeenCalled();
});
it('재시도 후 로딩 상태가 표시된다', async () => {
const mockRefetchDevices = jest.fn();
const mockRefetchData = jest.fn();
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '디바이스 목록을 불러올 수 없습니다.',
refetch: mockRefetchDevices
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: '센서 데이터를 불러올 수 없습니다.',
refetch: mockRefetchData
});
renderWithRouter(<Dashboard />);
const retryButton = screen.getByText('다시 시도');
fireEvent.click(retryButton);
// 재시도 후 로딩 상태로 변경
mockUseDevices.mockReturnValue({
devices: [],
loading: true,
error: null,
refetch: mockRefetchDevices
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: true,
error: null,
refetch: mockRefetchData
});
// 컴포넌트를 다시 렌더링하여 로딩 상태 확인
const { rerender } = renderWithRouter(<Dashboard />);
// 로딩 상태로 변경
mockUseDevices.mockReturnValue({
devices: [],
loading: true,
error: null,
refetch: mockRefetchDevices
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: true,
error: null,
refetch: mockRefetchData
});
rerender(
<BrowserRouter>
<Dashboard />
</BrowserRouter>
);
expect(screen.getByText('데이터를 불러오는 중...')).toBeInTheDocument();
});
});
describe('부분적 오류 시나리오', () => {
it('디바이스 목록만 오류가 발생한 경우 센서 데이터는 정상 표시된다', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '디바이스 목록을 불러올 수 없습니다.',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 오류 메시지가 표시되지만 센서 데이터도 함께 표시됨
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('25.5°C')).toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
});
it('센서 데이터만 오류가 발생한 경우 디바이스 목록은 정상 표시된다', () => {
mockUseDevices.mockReturnValue({
devices: [
{
id: 1,
device_id: 'sensor-001',
name: '테스트 센서',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
}
],
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: '센서 데이터를 불러올 수 없습니다.',
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 오류 메시지가 표시되지만 디바이스 정보도 함께 표시됨
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('센서 데이터를 불러올 수 없습니다.')).toBeInTheDocument();
expect(screen.getByText('디바이스 상태')).toBeInTheDocument();
});
});
describe('오류 복구 시나리오', () => {
it('오류 후 정상 데이터가 로드되면 오류 메시지가 사라진다', async () => {
// 초기 오류 상태
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '디바이스 목록을 불러올 수 없습니다.',
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
const { rerender } = renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
// 정상 데이터로 복구
mockUseDevices.mockReturnValue({
devices: [
{
id: 1,
device_id: 'sensor-001',
name: '테스트 센서',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
}
],
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
],
loading: false,
error: null,
refetch: jest.fn()
});
rerender(
<BrowserRouter>
<Dashboard />
</BrowserRouter>
);
// 오류 메시지가 사라지고 정상 데이터가 표시됨
await waitFor(() => {
expect(screen.queryByText('오류가 발생했습니다')).not.toBeInTheDocument();
expect(screen.getByText('25.5°C')).toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
});
});
});
});
\ No newline at end of file
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Dashboard from '../pages/Dashboard';
import {
transformSensorDataToChartData,
transformSensorDataToCardData,
validateChartData,
createDefaultChartData,
createDefaultCardData
} from '../utils/dataTransformers';
import { useDevices } from '../hooks/useDevices';
import { useSensorData } from '../hooks/useSensorData';
// Mock the hooks
jest.mock('../hooks/useDevices');
jest.mock('../hooks/useSensorData');
const mockUseDevices = useDevices as jest.MockedFunction<typeof useDevices>;
const mockUseSensorData = useSensorData as jest.MockedFunction<typeof useSensorData>;
// Mock Recharts components
jest.mock('recharts', () => ({
LineChart: ({ children, data }: any) => (
<div data-testid="line-chart" data-chart-data={JSON.stringify(data)}>
{children}
</div>
),
Line: ({ dataKey }: any) => (
<div data-testid={`line-${dataKey}`}>Line: {dataKey}</div>
),
XAxis: () => <div data-testid="x-axis">XAxis</div>,
YAxis: () => <div data-testid="y-axis">YAxis</div>,
CartesianGrid: () => <div data-testid="cartesian-grid">CartesianGrid</div>,
Tooltip: () => <div data-testid="tooltip">Tooltip</div>,
Legend: () => <div data-testid="legend">Legend</div>,
ResponsiveContainer: ({ children }: any) => (
<div data-testid="responsive-container">{children}</div>
),
}));
const renderWithRouter = (component: React.ReactElement) => {
return render(
<BrowserRouter>
{component}
</BrowserRouter>
);
};
describe('Dashboard 데이터 변환 테스트', () => {
const mockDevices = [
{
id: 1,
device_id: 'sensor-001',
name: '테스트 센서',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
}
];
beforeEach(() => {
jest.clearAllMocks();
// 기본 mock 설정
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
});
describe('데이터 변환 유틸리티 함수 테스트', () => {
it('transformSensorDataToChartData가 빈 배열을 처리한다', () => {
const result = transformSensorDataToChartData([]);
expect(result).toEqual(createDefaultChartData());
});
it('transformSensorDataToChartData가 null 데이터를 처리한다', () => {
const result = transformSensorDataToChartData(null as any);
expect(result).toEqual(createDefaultChartData());
});
it('transformSensorDataToChartData가 유효한 데이터를 변환한다', () => {
const validData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
];
const result = transformSensorDataToChartData(validData);
expect(result).toHaveLength(1);
expect(result[0].device_id).toBe('sensor-001');
expect(result[0].temperature).toBe(25.5);
expect(result[0].humidity).toBe(60.0);
});
it('transformSensorDataToChartData가 잘못된 데이터를 필터링한다', () => {
const invalidData = [
{ invalid_key: 'test' }, // device_id가 없음
{ device_id: 'sensor-001', temperature: 'invalid' }, // 숫자가 아닌 값
{ device_id: 'sensor-002', temperature: 25.5, humidity: 60.0 } // 유효한 데이터
];
const result = transformSensorDataToChartData(invalidData);
// 유효한 데이터만 처리됨
expect(result).toHaveLength(1);
expect(result[0].device_id).toBe('sensor-002');
});
it('transformSensorDataToCardData가 null 데이터를 처리한다', () => {
const result = transformSensorDataToCardData(null);
expect(result).toEqual(createDefaultCardData());
});
it('transformSensorDataToCardData가 유효한 데이터를 변환한다', () => {
const validData = {
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
};
const result = transformSensorDataToCardData(validData);
expect(result).toHaveLength(10); // 모든 센서 타입
expect(result[0].title).toBe('현재 온도');
expect(result[0].value).toBe(25.5);
expect(result[0].unit).toBe('°C');
});
it('validateChartData가 유효한 데이터를 검증한다', () => {
const validData = createDefaultChartData();
expect(validateChartData(validData)).toBe(true);
});
it('validateChartData가 잘못된 데이터를 감지한다', () => {
const invalidData = [
{ device_id: 'test' } // 필수 필드 누락
] as any;
expect(validateChartData(invalidData)).toBe(false);
});
});
describe('Dashboard 컴포넌트 데이터 변환 테스트', () => {
it('빈 데이터 시 기본 카드들이 렌더링된다', () => {
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 기본 센서 카드들이 렌더링되는지 확인
expect(screen.getByText('현재 온도')).toBeInTheDocument();
expect(screen.getByText('현재 습도')).toBeInTheDocument();
expect(screen.getByText('PM10')).toBeInTheDocument();
expect(screen.getByText('PM2.5')).toBeInTheDocument();
expect(screen.getByText('기압')).toBeInTheDocument();
expect(screen.getByText('조도')).toBeInTheDocument();
expect(screen.getByText('TVOC')).toBeInTheDocument();
expect(screen.getByText('CO2')).toBeInTheDocument();
expect(screen.getByText('O2')).toBeInTheDocument();
expect(screen.getByText('CO')).toBeInTheDocument();
});
it('유효한 센서 데이터가 올바르게 변환되어 표시된다', async () => {
const validData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseSensorData.mockReturnValue({
latestData: validData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 변환된 값들이 올바르게 표시되는지 확인
await waitFor(() => {
expect(screen.getByText('25.5°C')).toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
expect(screen.getByText('15.2μg/m³')).toBeInTheDocument();
expect(screen.getByText('8.1μg/m³')).toBeInTheDocument();
expect(screen.getByText('1013.3hPa')).toBeInTheDocument();
expect(screen.getByText('500lux')).toBeInTheDocument();
expect(screen.getByText('120ppb')).toBeInTheDocument();
expect(screen.getByText('450ppm')).toBeInTheDocument();
expect(screen.getByText('20.9%')).toBeInTheDocument();
expect(screen.getByText('0.5ppm')).toBeInTheDocument();
});
});
it('잘못된 데이터가 포함된 경우 유효한 데이터만 처리한다', async () => {
const mixedData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
},
{
device_id: 'sensor-002',
temperature: null,
humidity: undefined,
pm10: NaN,
pm25: Infinity,
pressure: 'invalid',
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseSensorData.mockReturnValue({
latestData: mixedData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 첫 번째 센서의 유효한 데이터가 표시되는지 확인
await waitFor(() => {
expect(screen.getByText('25.5°C')).toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
expect(screen.getByText('15.2μg/m³')).toBeInTheDocument();
expect(screen.getByText('8.1μg/m³')).toBeInTheDocument();
});
});
it('차트 데이터가 올바르게 변환되어 렌더링된다', async () => {
const validData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseSensorData.mockReturnValue({
latestData: validData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 차트들이 렌더링되는지 확인 (여러 개의 차트가 있음)
await waitFor(() => {
const charts = screen.getAllByTestId('line-chart');
expect(charts.length).toBeGreaterThan(0);
});
// 첫 번째 차트의 데이터가 올바르게 변환되었는지 확인
const charts = screen.getAllByTestId('line-chart');
const firstChartData = charts[0].getAttribute('data-chart-data');
const parsedData = JSON.parse(firstChartData || '[]');
expect(parsedData).toHaveLength(1);
expect(parsedData[0].device_id).toBe('sensor-001');
expect(parsedData[0].temperature).toBe(25.5);
expect(parsedData[0].humidity).toBe(60.0);
});
});
describe('데이터 변환 오류 처리 테스트', () => {
it('데이터 변환 중 오류가 발생하면 기본 데이터를 사용한다', async () => {
// 잘못된 데이터 구조로 오류 유발
const invalidData = [
{
device_id: 'sensor-001',
temperature: 'invalid_string', // 숫자가 아닌 값
humidity: null,
pm10: undefined,
pm25: NaN,
pressure: Infinity,
illumination: -Infinity,
tvoc: 'not_a_number',
co2: {},
o2: [],
co: () => {},
recorded_time: 123 // 문자열이어야 함
}
];
mockUseSensorData.mockReturnValue({
latestData: invalidData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 기본값들이 표시되는지 확인
await waitFor(() => {
expect(screen.getByText('0.0°C')).toBeInTheDocument();
expect(screen.getByText('0.0%')).toBeInTheDocument();
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument();
});
});
it('빈 배열이 전달되면 기본 차트 데이터가 사용된다', async () => {
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
await waitFor(() => {
const charts = screen.getAllByTestId('line-chart');
expect(charts.length).toBeGreaterThan(0);
});
const charts = screen.getAllByTestId('line-chart');
const firstChartData = charts[0].getAttribute('data-chart-data');
const parsedData = JSON.parse(firstChartData || '[]');
expect(parsedData).toEqual(createDefaultChartData());
});
});
});
\ No newline at end of file
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Dashboard from '../pages/Dashboard';
import { getDevices, getLatestData } from '../services/api';
// Mock the API services
jest.mock('../services/api');
const mockGetDevices = getDevices as jest.MockedFunction<typeof getDevices>;
const mockGetLatestData = getLatestData as jest.MockedFunction<typeof getLatestData>;
// Mock the hooks
jest.mock('../hooks/useDevices');
jest.mock('../hooks/useSensorData');
const mockUseDevices = require('../hooks/useDevices').useDevices;
const mockUseSensorData = require('../hooks/useSensorData').useSensorData;
const renderWithRouter = (component: React.ReactElement) => {
return render(
<BrowserRouter>
{component}
</BrowserRouter>
);
};
describe('Dashboard Integration', () => {
const mockDevices = [
{
id: 1,
device_id: 'sensor-001',
name: '테스트 센서',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
}
];
const mockSensorData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
];
beforeEach(() => {
jest.clearAllMocks();
// Mock useDevices hook
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
// Mock useSensorData hook
mockUseSensorData.mockReturnValue({
latestData: mockSensorData,
loading: false,
error: null,
refetch: jest.fn()
});
});
it('should render dashboard with sensor data', async () => {
renderWithRouter(<Dashboard />);
// Check if main title is rendered
expect(screen.getByText('센서 대시보드')).toBeInTheDocument();
// Check if sensor cards are rendered
await waitFor(() => {
expect(screen.getByText('현재 온도')).toBeInTheDocument();
expect(screen.getByText('현재 습도')).toBeInTheDocument();
expect(screen.getByText('PM10')).toBeInTheDocument();
expect(screen.getByText('PM2.5')).toBeInTheDocument();
});
// Check if sensor values are displayed correctly
expect(screen.getByText('25.5°C')).toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
expect(screen.getByText('15.2μg/m³')).toBeInTheDocument();
expect(screen.getByText('8.1μg/m³')).toBeInTheDocument();
});
it('should display loading state', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: true,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: true,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('데이터를 불러오는 중...')).toBeInTheDocument();
});
it('should display error state', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '디바이스 목록을 불러올 수 없습니다.',
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
expect(screen.getByText('디바이스 목록을 불러올 수 없습니다.')).toBeInTheDocument();
expect(screen.getByText('다시 시도')).toBeInTheDocument();
});
it('should display device status table', async () => {
renderWithRouter(<Dashboard />);
await waitFor(() => {
expect(screen.getByText('디바이스 상태')).toBeInTheDocument();
expect(screen.getByText('sensor-001')).toBeInTheDocument();
expect(screen.getByText('테스트 센서')).toBeInTheDocument();
expect(screen.getByText('active')).toBeInTheDocument();
});
});
it('should display summary cards', async () => {
renderWithRouter(<Dashboard />);
await waitFor(() => {
expect(screen.getByText('활성 디바이스')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument(); // active device count
expect(screen.getByText('총 데이터')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument(); // total data count
});
});
it('should handle empty data gracefully', () => {
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('센서 대시보드')).toBeInTheDocument();
expect(screen.getByText('활성 디바이스')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument(); // no active devices
});
it('should handle sensor data with invalid values', () => {
const invalidSensorData = [
{
device_id: 'sensor-001',
temperature: 1e-40, // very small value
humidity: undefined,
pm10: null as any,
pm25: Infinity,
pressure: NaN,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseSensorData.mockReturnValue({
latestData: invalidSensorData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// Should display formatted values for invalid data
expect(screen.getByText('0.0°C')).toBeInTheDocument(); // very small value
expect(screen.getByText('N/A%')).toBeInTheDocument(); // undefined
expect(screen.getByText('N/Aμg/m³')).toBeInTheDocument(); // null
expect(screen.getByText('N/Aμg/m³')).toBeInTheDocument(); // Infinity
expect(screen.getByText('N/AhPa')).toBeInTheDocument(); // NaN
});
it('should display all sensor types', async () => {
const completeSensorData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: 1013.25,
illumination: 500,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseSensorData.mockReturnValue({
latestData: completeSensorData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
await waitFor(() => {
// Check all sensor cards are rendered
expect(screen.getByText('현재 온도')).toBeInTheDocument();
expect(screen.getByText('현재 습도')).toBeInTheDocument();
expect(screen.getByText('PM10')).toBeInTheDocument();
expect(screen.getByText('PM2.5')).toBeInTheDocument();
expect(screen.getByText('기압')).toBeInTheDocument();
expect(screen.getByText('조도')).toBeInTheDocument();
expect(screen.getByText('TVOC')).toBeInTheDocument();
expect(screen.getByText('CO2')).toBeInTheDocument();
expect(screen.getByText('O2')).toBeInTheDocument();
expect(screen.getByText('CO')).toBeInTheDocument();
});
});
it('should handle retry functionality', async () => {
const mockRefetchDevices = jest.fn();
const mockRefetchData = jest.fn();
mockUseDevices.mockReturnValue({
devices: [],
loading: false,
error: '디바이스 목록을 불러올 수 없습니다.',
refetch: mockRefetchDevices
});
mockUseSensorData.mockReturnValue({
latestData: [],
loading: false,
error: '센서 데이터를 불러올 수 없습니다.',
refetch: mockRefetchData
});
renderWithRouter(<Dashboard />);
const retryButton = screen.getByText('다시 시도');
retryButton.click();
expect(mockRefetchDevices).toHaveBeenCalled();
expect(mockRefetchData).toHaveBeenCalled();
});
});
\ No newline at end of file
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
componentName?: string;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
autoRecover?: boolean;
maxRetries?: number;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
retryCount: number;
isRecovering: boolean;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
retryCount: 0,
isRecovering: false
};
private retryTimeout?: NodeJS.Timeout;
private readonly maxRetries: number;
constructor(props: Props) {
super(props);
this.maxRetries = props.maxRetries || 3;
}
public static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
// Recharts 관련 오류인 경우 추가 정보 로깅
if (this.isRechartsError(error)) {
this.logRechartsError(error, errorInfo);
}
// 일반적인 React 오류 로깅
this.logGeneralError(error, errorInfo);
// 에러 콜백 호출
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
this.setState({ errorInfo });
// 자동 복구 로직
if (this.props.autoRecover && this.state.retryCount < this.maxRetries) {
this.scheduleAutoRecovery();
}
}
private isRechartsError(error: Error): boolean {
return error.message.includes('Invariant failed') ||
error.stack?.includes('generateCategoricalChart') ||
error.message.includes('Recharts') ||
error.stack?.includes('recharts') ||
error.message.includes('categorical') ||
error.message.includes('chart');
}
private logRechartsError(error: Error, errorInfo: ErrorInfo) {
console.error('🚨 Recharts 차트 오류 감지:', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
componentName: this.props.componentName || 'Unknown',
retryCount: this.state.retryCount
});
// 추가 디버깅 정보 수집
this.logChartDebugInfo();
}
private logGeneralError(error: Error, errorInfo: ErrorInfo) {
console.error('🔴 일반 오류 감지:', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
componentName: this.props.componentName || 'Unknown',
retryCount: this.state.retryCount
});
}
private logChartDebugInfo() {
try {
// DOM에서 차트 관련 요소들 찾기
const chartElements = document.querySelectorAll('[class*="recharts"]');
console.log('📊 차트 요소 개수:', chartElements.length);
// 데이터 관련 정보 수집
const dataElements = document.querySelectorAll('[data-testid*="chart"]');
console.log('📈 데이터 요소 개수:', dataElements.length);
// 메모리 사용량 확인
if ('memory' in performance) {
console.log('💾 메모리 사용량:', (performance as any).memory);
}
// React DevTools 정보
if ((window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__) {
console.log('🔧 React DevTools 감지됨');
}
} catch (debugError) {
console.error('디버깅 정보 수집 중 오류:', debugError);
}
}
private scheduleAutoRecovery() {
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
const delay = Math.min(1000 * Math.pow(2, this.state.retryCount), 10000); // 지수 백오프, 최대 10초
this.setState({ isRecovering: true });
this.retryTimeout = setTimeout(() => {
this.setState(prevState => ({
hasError: false,
error: undefined,
errorInfo: undefined,
retryCount: prevState.retryCount + 1,
isRecovering: false
}));
}, delay);
}
private handleRetry = () => {
this.setState(prevState => ({
hasError: false,
error: undefined,
errorInfo: undefined,
retryCount: prevState.retryCount + 1,
isRecovering: false
}));
};
private handleReload = () => {
window.location.reload();
};
private getErrorMessage(): string {
if (!this.state.error) return '알 수 없는 오류가 발생했습니다.';
const error = this.state.error;
// Recharts 관련 오류 메시지
if (this.isRechartsError(error)) {
return '차트 데이터 처리 중 오류가 발생했습니다.';
}
// 일반적인 React 오류
if (error.message.includes('React')) {
return '컴포넌트 렌더링 중 오류가 발생했습니다.';
}
// 네트워크 오류
if (error.message.includes('Network') || error.message.includes('fetch')) {
return '네트워크 연결에 문제가 있습니다.';
}
// 타입 오류
if (error.message.includes('Type') || error.message.includes('undefined')) {
return '데이터 형식에 문제가 있습니다.';
}
return error.message || '알 수 없는 오류가 발생했습니다.';
}
private getErrorDetails(): string {
if (!this.state.error) return '';
const error = this.state.error;
if (this.isRechartsError(error)) {
return '차트 라이브러리에서 데이터 구조를 처리할 수 없습니다. 데이터를 확인하거나 페이지를 새로고침해주세요.';
}
if (error.message.includes('Network') || error.message.includes('fetch')) {
return '인터넷 연결을 확인하고 다시 시도해주세요.';
}
if (error.message.includes('Type') || error.message.includes('undefined')) {
return '데이터 형식을 확인하고 다시 시도해주세요.';
}
return '페이지를 새로고침하거나 잠시 후 다시 시도해주세요.';
}
private getRetryMessage(): string {
if (this.state.retryCount === 0) return '';
if (this.state.isRecovering) {
return `자동 복구 시도 중... (${this.state.retryCount}/${this.maxRetries})`;
}
return `재시도 횟수: ${this.state.retryCount}/${this.maxRetries}`;
}
public componentWillUnmount() {
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
}
}
public render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="mt-4 text-center">
<h3 className="text-lg font-medium text-gray-900">오류가 발생했습니다</h3>
{this.props.componentName && (
<p className="text-sm text-gray-500 mt-1">
컴포넌트: {this.props.componentName}
</p>
)}
<p className="mt-2 text-sm text-gray-600 mb-2">
{this.getErrorMessage()}
</p>
<p className="text-xs text-gray-500 mb-2">
{this.getErrorDetails()}
</p>
{this.getRetryMessage() && (
<p className="text-xs text-blue-600 mb-4">
{this.getRetryMessage()}
</p>
)}
<div className="space-y-2">
<button
onClick={this.handleRetry}
disabled={this.state.isRecovering}
className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{this.state.isRecovering ? '복구 중...' : '다시 시도'}
</button>
<button
onClick={this.handleReload}
className="w-full inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
페이지 새로고침
</button>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
\ No newline at end of file
import React, { useState } from 'react';
import { exportCurrentData } from '../utils/exportData';
import { SensorData } from '../services/api';
interface ExportButtonProps {
data: SensorData[];
className?: string;
}
const ExportButton: React.FC<ExportButtonProps> = ({ data, className = '' }) => {
const [isOpen, setIsOpen] = useState(false);
const handleExport = (format: 'csv' | 'json') => {
exportCurrentData(data, format);
setIsOpen(false);
};
return (
<div className={`relative ${className}`}>
<button
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
내보내기
<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50">
<div className="py-1" role="menu">
<button
onClick={() => handleExport('csv')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
role="menuitem"
>
<div className="flex items-center">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
CSV로 내보내기
</div>
</button>
<button
onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
role="menuitem"
>
<div className="flex items-center">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
JSON으로 내보내기
</div>
</button>
</div>
</div>
)}
</div>
);
};
export default ExportButton;
\ No newline at end of file
import React from 'react';
import ThemeToggle from './ThemeToggle';
const Header: React.FC = () => {
return (
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
센서 데이터 대시보드
</h1>
</div>
<div className="flex items-center space-x-4">
<ThemeToggle />
</div>
</div>
</div>
</header>
);
};
export default Header;
\ No newline at end of file
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
className?: string;
}
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
text = '로딩 중...',
className = ''
}) => {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12'
};
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<div className={`animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 ${sizeClasses[size]}`} />
{text && (
<p className="mt-2 text-sm text-gray-600">{text}</p>
)}
</div>
);
};
export default LoadingSpinner;
\ No newline at end of file
import React, { memo } from 'react';
import { formatSensorValue, getSensorPrecision } from '../utils/formatters';
import Tooltip from './Tooltip';
interface SensorCardProps {
title: string;
value: number | undefined;
unit: string;
icon: string;
bgColor: string;
precision?: number;
description?: string;
}
const SensorCard: React.FC<SensorCardProps> = memo(({
title,
value,
unit,
icon,
bgColor,
precision,
description
}) => {
const displayValue = value !== undefined
? formatSensorValue(value, precision || getSensorPrecision(title.toLowerCase()))
: 'N/A';
const cardContent = (
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center">
<div className={`p-2 ${bgColor} rounded-lg`}>
<span className="text-2xl">{icon}</span>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">
{displayValue} {unit}
</p>
</div>
</div>
</div>
);
return description ? (
<Tooltip content={description} position="top">
{cardContent}
</Tooltip>
) : cardContent;
});
SensorCard.displayName = 'SensorCard';
export default SensorCard;
\ No newline at end of file
import React, { memo, useMemo, useCallback } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { formatSensorValue, getSensorPrecision } from '../utils/formatters';
interface SensorChartProps {
title: string;
data: any[];
dataKeys: string[];
colors: string[];
names?: string[];
height?: number;
yAxisId?: string;
rightYAxisId?: string;
rightDataKeys?: string[];
rightColors?: string[];
rightNames?: string[];
}
// 데이터 유효성 검증 함수 - 강화된 검증
const validateChartData = (data: any[], dataKeys: string[], rightDataKeys: string[]): boolean => {
if (!data || !Array.isArray(data) || data.length === 0) {
return false;
}
// 모든 데이터 항목이 유효한지 확인
return data.every(item => {
if (!item || typeof item !== 'object' || !item.device_id) {
return false;
}
// 모든 dataKeys가 유효한 숫자인지 확인
const allKeys = [...dataKeys, ...rightDataKeys];
return allKeys.every(key => {
const value = item[key];
return typeof value === 'number' && !isNaN(value) && isFinite(value);
});
});
};
// 기본 더미 데이터 생성 함수
const createDefaultData = (dataKeys: string[], rightDataKeys: string[]) => {
const allKeys = [...dataKeys, ...rightDataKeys];
const defaultItem: any = {
device_id: 'No Data'
};
allKeys.forEach(key => {
defaultItem[key] = 0;
});
return [defaultItem];
};
// 안전한 숫자 변환 함수
const safeNumber = (value: any, defaultValue: number = 0): number => {
if (value === null || value === undefined) {
return defaultValue;
}
const num = Number(value);
if (typeof num === 'number' && !isNaN(num) && isFinite(num)) {
return num;
}
return defaultValue;
};
const SensorChart: React.FC<SensorChartProps> = memo(({
title,
data,
dataKeys,
colors,
names,
height = 300,
yAxisId,
rightYAxisId,
rightDataKeys = [],
rightColors = [],
rightNames = []
}) => {
// 차트 데이터 메모이제이션 및 검증 - 의존성 배열 최적화
const chartData = useMemo(() => {
// 데이터가 없거나 빈 배열인 경우 기본 데이터 반환
if (!data || !Array.isArray(data) || data.length === 0) {
return createDefaultData(dataKeys, rightDataKeys);
}
// 데이터 유효성 검사 및 정리
const processedData = data
.filter(item => item && typeof item === 'object' && item.device_id)
.map(item => {
const processedItem: any = {
device_id: item.device_id || 'Unknown',
};
// 모든 dataKeys에 대해 안전하게 값 추출
dataKeys.forEach(key => {
processedItem[key] = safeNumber(item[key], 0);
});
// 모든 rightDataKeys에 대해 안전하게 값 추출
rightDataKeys.forEach(key => {
processedItem[key] = safeNumber(item[key], 0);
});
return processedItem;
});
// 처리된 데이터가 비어있으면 기본 데이터 반환
if (processedData.length === 0) {
return createDefaultData(dataKeys, rightDataKeys);
}
return processedData;
}, [data, dataKeys, rightDataKeys]);
// 데이터 유효성 검사 - 메모이제이션 최적화
const isValidData = useMemo(() => {
return validateChartData(chartData, dataKeys, rightDataKeys);
}, [chartData, dataKeys, rightDataKeys]);
// 툴팁 포맷터 메모이제이션 - useCallback 사용
const tooltipFormatter = useCallback((value: any, name: string) => {
const sensorType = name.toLowerCase().replace(/\s+/g, '');
return [formatSensorValue(value, getSensorPrecision(sensorType)), name];
}, []);
// Y축 설정 메모이제이션
const yAxisConfig = useMemo(() => {
const config = {
hasLeftYAxis: false,
hasRightYAxis: false,
leftYAxis: null as React.ReactElement | null,
rightYAxis: null as React.ReactElement | null,
defaultYAxis: null as React.ReactElement | null
};
if (yAxisId) {
config.hasLeftYAxis = true;
config.leftYAxis = <YAxis yAxisId={yAxisId} stroke="#6B7280" tick={{ fill: '#6B7280' }} />;
}
if (rightYAxisId) {
config.hasRightYAxis = true;
config.rightYAxis = <YAxis yAxisId={rightYAxisId} orientation="right" stroke="#6B7280" tick={{ fill: '#6B7280' }} />;
}
if (!yAxisId && !rightYAxisId) {
config.defaultYAxis = <YAxis stroke="#6B7280" tick={{ fill: '#6B7280' }} />;
}
return config;
}, [yAxisId, rightYAxisId]);
// 라인 컴포넌트 메모이제이션
const leftLines = useMemo(() => {
return dataKeys.map((key, index) => {
// 데이터 키가 실제로 존재하는지 확인
const hasData = chartData.some(item => typeof item[key] === 'number' && !isNaN(item[key]));
if (!hasData) return null;
return (
<Line
key={key}
yAxisId={yAxisId}
type="monotone"
dataKey={key}
stroke={colors[index] || '#3b82f6'}
strokeWidth={2}
name={names?.[index] || key}
dot={false}
/>
);
}).filter(Boolean);
}, [dataKeys, colors, names, yAxisId, chartData]);
const rightLines = useMemo(() => {
return rightDataKeys.map((key, index) => {
// 데이터 키가 실제로 존재하는지 확인
const hasData = chartData.some(item => typeof item[key] === 'number' && !isNaN(item[key]));
if (!hasData) return null;
return (
<Line
key={`right-${key}`}
yAxisId={rightYAxisId}
type="monotone"
dataKey={key}
stroke={rightColors[index] || '#10b981'}
strokeWidth={2}
name={rightNames?.[index] || key}
dot={false}
/>
);
}).filter(Boolean);
}, [rightDataKeys, rightColors, rightNames, rightYAxisId, chartData]);
// 데이터가 없거나 유효하지 않은 경우 안내 메시지 표시
if (chartData.length === 0 || !isValidData) {
return (
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">{title}</h3>
<div className="flex items-center justify-center h-[300px]">
<div className="text-center">
<div className="text-gray-400 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<p className="text-sm text-gray-500">
{chartData.length === 0 ? '데이터가 없습니다' : '유효한 데이터가 없습니다'}
</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">{title}</h3>
<ResponsiveContainer width="100%" height={height}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="device_id"
stroke="#6B7280"
tick={{ fill: '#6B7280' }}
/>
{/* Y축 설정 - 메모이제이션된 설정 사용 */}
{yAxisConfig.leftYAxis}
{yAxisConfig.rightYAxis}
{yAxisConfig.defaultYAxis}
<Tooltip
formatter={tooltipFormatter}
contentStyle={{
backgroundColor: '#1F2937',
border: '1px solid #374151',
borderRadius: '8px',
color: '#F9FAFB'
}}
/>
<Legend />
{/* 메모이제이션된 라인 컴포넌트들 */}
{leftLines}
{rightLines}
</LineChart>
</ResponsiveContainer>
</div>
);
});
SensorChart.displayName = 'SensorChart';
export default SensorChart;
\ No newline at end of file
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
const Sidebar: React.FC = () => {
const location = useLocation();
const menuItems = [
{ path: '/dashboard', label: '대시보드', icon: '📊' },
{ path: '/devices', label: '디바이스', icon: '📱' },
{ path: '/history', label: '히스토리', icon: '📈' },
{ path: '/settings', label: '설정', icon: '⚙️' },
];
return (
<aside className="w-64 bg-white dark:bg-gray-900 shadow-sm border-r border-gray-200 dark:border-gray-700 min-h-screen">
<nav className="mt-8">
<ul className="space-y-2">
{menuItems.map((item) => (
<li key={item.path}>
<Link
to={item.path}
className={`flex items-center px-6 py-3 text-sm font-medium rounded-md transition-colors ${
location.pathname === item.path
? 'bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-300 border-r-2 border-blue-700 dark:border-blue-300'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
<span className="mr-3">{item.icon}</span>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
</aside>
);
};
export default Sidebar;
\ No newline at end of file
import React from 'react';
import { ConnectionStatus } from '../hooks/useRealTimeData';
interface StatusIndicatorProps {
isConnected: boolean;
connectionStatus?: ConnectionStatus;
dataSource: 'websocket' | 'polling';
lastUpdate?: string;
className?: string;
error?: string | null;
retryCount?: Map<string, number>;
}
const StatusIndicator: React.FC<StatusIndicatorProps> = ({
isConnected,
connectionStatus,
dataSource,
lastUpdate,
className = '',
error,
retryCount
}) => {
const getStatusColor = () => {
if (connectionStatus) {
switch (connectionStatus) {
case 'connected':
return 'text-green-600';
case 'connecting':
return 'text-yellow-600';
case 'retrying':
return 'text-orange-600';
case 'error':
case 'disconnected':
return 'text-red-600';
default:
return 'text-gray-600';
}
}
if (!isConnected) return 'text-red-600';
return dataSource === 'websocket' ? 'text-green-600' : 'text-yellow-600';
};
const getStatusText = () => {
if (connectionStatus) {
switch (connectionStatus) {
case 'connected':
return '연결됨';
case 'connecting':
return '연결 중...';
case 'retrying':
return '재시도 중...';
case 'error':
return '연결 오류';
case 'disconnected':
return '연결 끊김';
default:
return '알 수 없음';
}
}
if (!isConnected) return '연결 끊김';
return dataSource === 'websocket' ? '실시간 연결' : '폴링 모드';
};
const getStatusIcon = () => {
if (connectionStatus) {
switch (connectionStatus) {
case 'connected':
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
case 'connecting':
case 'retrying':
return (
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
);
case 'error':
case 'disconnected':
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
default:
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
}
if (dataSource === 'websocket') {
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
}
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
};
const getDataSourceText = () => {
return dataSource === 'websocket' ? 'WebSocket' : '폴링';
};
const getRetryInfo = () => {
if (!retryCount || retryCount.size === 0) return null;
const totalRetries = Array.from(retryCount.values()).reduce((sum, count) => sum + count, 0);
if (totalRetries === 0) return null;
return (
<div className="text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded">
재시도: {totalRetries}
</div>
);
};
return (
<div className={`flex items-center space-x-3 text-sm ${className}`}>
{/* 연결 상태 */}
<div className={`flex items-center space-x-1 ${getStatusColor()}`}>
<div className={`w-2 h-2 rounded-full ${
connectionStatus === 'connected' ? 'bg-green-500' :
connectionStatus === 'connecting' || connectionStatus === 'retrying' ? 'bg-yellow-500' :
connectionStatus === 'error' || connectionStatus === 'disconnected' ? 'bg-red-500' :
isConnected ? 'bg-green-500' : 'bg-red-500'
}`} />
{getStatusIcon()}
<span className="font-medium">{getStatusText()}</span>
</div>
{/* 데이터 소스 */}
<div className="flex items-center space-x-1 text-gray-500">
<span>{getDataSourceText()}</span>
</div>
{/* 재시도 정보 */}
{getRetryInfo()}
{/* 마지막 업데이트 */}
{lastUpdate && (
<div className="text-gray-400 text-xs">
마지막 업데이트: {new Date(lastUpdate).toLocaleTimeString()}
</div>
)}
{/* 에러 메시지 */}
{error && (
<div className="text-red-600 text-xs bg-red-50 px-2 py-1 rounded max-w-xs truncate" title={error}>
{error}
</div>
)}
</div>
);
};
export default StatusIndicator;
\ No newline at end of file
import React, { useState } from 'react';
import { useMonitoring } from '../hooks/useMonitoring';
import { logger } from '../utils/logger';
const SystemMonitor: React.FC = () => {
const { metrics, performanceMetrics } = useMonitoring();
const [showDetails, setShowDetails] = useState(false);
const [logStats] = useState(() => logger.getLogStats());
const getStatusColor = (status: string) => {
switch (status) {
case 'connected':
return 'text-green-600';
case 'connecting':
return 'text-yellow-600';
case 'disconnected':
return 'text-red-600';
default:
return 'text-gray-600';
}
};
const getQualityColor = (score: number) => {
if (score >= 90) return 'text-green-600';
if (score >= 70) return 'text-yellow-600';
return 'text-red-600';
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">시스템 모니터링</h3>
<button
onClick={() => setShowDetails(!showDetails)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{showDetails ? '간단히 보기' : '상세 보기'}
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{/* 메모리 사용량 */}
<div className="text-center">
<div className="text-2xl font-semibold text-gray-900 dark:text-white">
{metrics.memoryUsage}%
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">메모리</div>
</div>
{/* 네트워크 지연 */}
<div className="text-center">
<div className="text-2xl font-semibold text-gray-900 dark:text-white">
{metrics.networkLatency}ms
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">네트워크</div>
</div>
{/* WebSocket 상태 */}
<div className="text-center">
<div className={`text-2xl font-semibold ${getStatusColor(metrics.websocketStatus)}`}>
{metrics.websocketStatus === 'connected' ? '연결됨' :
metrics.websocketStatus === 'connecting' ? '연결중' : '끊어짐'}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">WebSocket</div>
</div>
{/* 데이터 품질 */}
<div className="text-center">
<div className={`text-2xl font-semibold ${getQualityColor(metrics.dataQuality.qualityScore)}`}>
{metrics.dataQuality.qualityScore}%
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">데이터 품질</div>
</div>
</div>
{showDetails && (
<div className="space-y-4">
{/* 센서 상태 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">센서 상태</h4>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-gray-600 dark:text-gray-400">전체:</span>
<span className="ml-2 text-gray-900 dark:text-white">{metrics.sensorStatus.totalSensors}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">활성:</span>
<span className="ml-2 text-green-600">{metrics.sensorStatus.activeSensors}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">비활성:</span>
<span className="ml-2 text-red-600">{metrics.sensorStatus.inactiveSensors}</span>
</div>
</div>
</div>
{/* 성능 메트릭 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">성능 메트릭</h4>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-gray-600 dark:text-gray-400">API 응답:</span>
<span className="ml-2 text-gray-900 dark:text-white">{performanceMetrics.apiCallTime}ms</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">렌더링:</span>
<span className="ml-2 text-gray-900 dark:text-white">{performanceMetrics.componentRenderTime}ms</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">처리:</span>
<span className="ml-2 text-gray-900 dark:text-white">{performanceMetrics.dataProcessingTime}ms</span>
</div>
</div>
</div>
{/* 로그 통계 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">로그 통계</h4>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div>
<span className="text-gray-600 dark:text-gray-400">전체:</span>
<span className="ml-2 text-gray-900 dark:text-white">{logStats.total}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">Debug:</span>
<span className="ml-2 text-blue-600">{logStats.byLevel.debug}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">Info:</span>
<span className="ml-2 text-green-600">{logStats.byLevel.info}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">Warn:</span>
<span className="ml-2 text-yellow-600">{logStats.byLevel.warn}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">Error:</span>
<span className="ml-2 text-red-600">{logStats.byLevel.error}</span>
</div>
</div>
</div>
{/* 데이터 품질 상세 */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">데이터 품질 상세</h4>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-gray-600 dark:text-gray-400">전체 레코드:</span>
<span className="ml-2 text-gray-900 dark:text-white">{metrics.dataQuality.totalRecords}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">유효 레코드:</span>
<span className="ml-2 text-green-600">{metrics.dataQuality.validRecords}</span>
</div>
<div>
<span className="text-gray-600 dark:text-gray-400">무효 레코드:</span>
<span className="ml-2 text-red-600">{metrics.dataQuality.invalidRecords}</span>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default SystemMonitor;
\ No newline at end of file
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
const ThemeToggle: React.FC = () => {
const { theme, setTheme, isDark } = useTheme();
const themes = [
{ value: 'light', label: '라이트', icon: '☀️' },
{ value: 'dark', label: '다크', icon: '🌙' },
{ value: 'system', label: '시스템', icon: '💻' }
] as const;
return (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600 dark:text-gray-400">테마:</span>
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
{themes.map(({ value, label, icon }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
theme === value
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
title={label}
>
<span className="mr-1">{icon}</span>
{label}
</button>
))}
</div>
</div>
);
};
export default ThemeToggle;
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment