Commit 6423f0de authored by Sensor MVP Team's avatar Sensor MVP Team
Browse files

Merge master into main: Add comprehensive project files and documentation

parents d32dec3c fcfb9aec
import React, { useState } from 'react';
interface TooltipProps {
content: string;
children: React.ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
}
const Tooltip: React.FC<TooltipProps> = ({ content, children, position = 'top' }) => {
const [isVisible, setIsVisible] = useState(false);
const positionClasses = {
top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',
right: 'left-full top-1/2 transform -translate-y-1/2 ml-2'
};
const arrowClasses = {
top: 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100',
bottom: 'bottom-full left-1/2 transform -translate-x-1/2 border-b-gray-900 dark:border-b-gray-100',
left: 'left-full top-1/2 transform -translate-y-1/2 border-l-gray-900 dark:border-l-gray-100',
right: 'right-full top-1/2 transform -translate-y-1/2 border-r-gray-900 dark:border-r-gray-100'
};
return (
<div
className="relative inline-block"
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{children}
{isVisible && (
<div className={`absolute z-50 ${positionClasses[position]}`}>
<div className="bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-sm rounded-lg px-3 py-2 max-w-xs whitespace-normal">
{content}
<div className={`absolute w-0 h-0 border-4 border-transparent ${arrowClasses[position]}`} />
</div>
</div>
)}
</div>
);
};
export default Tooltip;
\ No newline at end of file
import React from 'react';
import { render, screen } from '@testing-library/react';
import SensorCard from '../SensorCard';
describe('SensorCard', () => {
const defaultProps = {
title: '테스트 센서',
value: 25.5,
unit: '°C',
icon: '🌡️',
bgColor: 'bg-blue-100',
precision: 1
};
it('should render sensor card with all props', () => {
render(<SensorCard {...defaultProps} />);
expect(screen.getByText('테스트 센서')).toBeInTheDocument();
expect(screen.getByText('25.5°C')).toBeInTheDocument();
expect(screen.getByText('🌡️')).toBeInTheDocument();
});
it('should format value with correct precision', () => {
render(<SensorCard {...defaultProps} value={25.123} precision={2} />);
expect(screen.getByText('25.12°C')).toBeInTheDocument();
});
it('should handle zero values', () => {
render(<SensorCard {...defaultProps} value={0} />);
expect(screen.getByText('0.0°C')).toBeInTheDocument();
});
it('should handle very small values', () => {
render(<SensorCard {...defaultProps} value={1e-40} />);
expect(screen.getByText('0.0°C')).toBeInTheDocument();
});
it('should handle undefined values', () => {
render(<SensorCard {...defaultProps} value={undefined} />);
expect(screen.getByText('N/A°C')).toBeInTheDocument();
});
it('should handle null values', () => {
render(<SensorCard {...defaultProps} value={null as any} />);
expect(screen.getByText('N/A°C')).toBeInTheDocument();
});
it('should handle infinite values', () => {
render(<SensorCard {...defaultProps} value={Infinity} />);
expect(screen.getByText('N/A°C')).toBeInTheDocument();
});
it('should handle NaN values', () => {
render(<SensorCard {...defaultProps} value={NaN} />);
expect(screen.getByText('N/A°C')).toBeInTheDocument();
});
it('should apply custom background color', () => {
render(<SensorCard {...defaultProps} bgColor="bg-red-100" />);
const card = screen.getByText('테스트 센서').closest('div');
expect(card).toHaveClass('bg-red-100');
});
it('should render without unit', () => {
render(<SensorCard {...defaultProps} unit="" />);
expect(screen.getByText('25.5')).toBeInTheDocument();
});
it('should render without icon', () => {
render(<SensorCard {...defaultProps} icon="" />);
expect(screen.queryByText('🌡️')).not.toBeInTheDocument();
});
it('should handle different sensor types', () => {
const testCases = [
{ title: '온도', value: 25.5, unit: '°C', icon: '🌡️' },
{ title: '습도', value: 60.0, unit: '%', icon: '💧' },
{ title: 'PM10', value: 15.2, unit: 'μg/m³', icon: '🌫️' },
{ title: '기압', value: 1013.25, unit: 'hPa', icon: '🌪️' }
];
testCases.forEach(({ title, value, unit, icon }) => {
const { unmount } = render(
<SensorCard
title={title}
value={value}
unit={unit}
icon={icon}
bgColor="bg-gray-100"
precision={1}
/>
);
expect(screen.getByText(title)).toBeInTheDocument();
expect(screen.getByText(`${value}${unit}`)).toBeInTheDocument();
expect(screen.getByText(icon)).toBeInTheDocument();
unmount();
});
});
it('should handle precision 0', () => {
render(<SensorCard {...defaultProps} value={25.7} precision={0} />);
expect(screen.getByText('26°C')).toBeInTheDocument();
});
it('should handle negative values', () => {
render(<SensorCard {...defaultProps} value={-10.5} />);
expect(screen.getByText('-10.5°C')).toBeInTheDocument();
});
});
\ No newline at end of file
import React from 'react';
import { render, screen } from '@testing-library/react';
import SensorChart from '../SensorChart';
// Recharts 컴포넌트들을 모킹
jest.mock('recharts', () => ({
LineChart: ({ children, data }: any) => (
<div data-testid="line-chart" data-chart-data={JSON.stringify(data)}>
{children}
</div>
),
Line: ({ dataKey, name }: any) => (
<div data-testid={`line-${dataKey}`} data-name={name}>
Line: {dataKey}
</div>
),
XAxis: ({ dataKey }: any) => (
<div data-testid="x-axis" data-key={dataKey}>
XAxis: {dataKey}
</div>
),
YAxis: ({ yAxisId }: any) => (
<div data-testid="y-axis" data-y-axis-id={yAxisId}>
YAxis
</div>
),
CartesianGrid: () => <div data-testid="cartesian-grid">CartesianGrid</div>,
Tooltip: ({ formatter }: any) => (
<div data-testid="tooltip" data-formatter={formatter ? 'true' : 'false'}>
Tooltip
</div>
),
Legend: () => <div data-testid="legend">Legend</div>,
ResponsiveContainer: ({ children }: any) => (
<div data-testid="responsive-container">
{children}
</div>
),
}));
describe('SensorChart', () => {
const defaultProps = {
title: '테스트 차트',
dataKeys: ['temperature', 'humidity'],
colors: ['#3b82f6', '#10b981'],
names: ['온도', '습도']
};
describe('빈 데이터 처리', () => {
it('빈 배열이 전달되면 기본 데이터를 사용하여 차트를 렌더링한다', () => {
render(<SensorChart {...defaultProps} data={[]} />);
// 차트가 렌더링되는지 확인
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
// 기본 데이터가 사용되는지 확인
const chartData = screen.getByTestId('line-chart').getAttribute('data-chart-data');
const parsedData = JSON.parse(chartData || '[]');
expect(parsedData).toHaveLength(1);
expect(parsedData[0]).toEqual({
device_id: 'No Data',
temperature: 0,
humidity: 0
});
});
it('null 데이터가 전달되면 기본 데이터를 사용하여 차트를 렌더링한다', () => {
render(<SensorChart {...defaultProps} data={null as any} />);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
const chartData = screen.getByTestId('line-chart').getAttribute('data-chart-data');
const parsedData = JSON.parse(chartData || '[]');
expect(parsedData).toHaveLength(1);
expect(parsedData[0].device_id).toBe('No Data');
});
it('undefined 데이터가 전달되면 기본 데이터를 사용하여 차트를 렌더링한다', () => {
render(<SensorChart {...defaultProps} data={undefined as any} />);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
const chartData = screen.getByTestId('line-chart').getAttribute('data-chart-data');
const parsedData = JSON.parse(chartData || '[]');
expect(parsedData).toHaveLength(1);
expect(parsedData[0].device_id).toBe('No Data');
});
it('유효하지 않은 데이터 구조가 전달되면 기본 데이터를 사용한다', () => {
const invalidData = [
{ invalid_key: 'test' }, // device_id가 없음
{ device_id: 'test', temperature: 'invalid' }, // 숫자가 아닌 값
{ device_id: 'test', humidity: NaN }, // NaN 값
{ device_id: 'test', temperature: Infinity } // Infinity 값
];
render(<SensorChart {...defaultProps} data={invalidData} />);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
const chartData = screen.getByTestId('line-chart').getAttribute('data-chart-data');
const parsedData = JSON.parse(chartData || '[]');
// device_id가 있는 데이터는 처리되지만, 유효하지 않은 값들은 0으로 변환됨
expect(parsedData).toHaveLength(3); // device_id가 있는 3개 데이터
expect(parsedData[0].device_id).toBe('test');
expect(parsedData[0].temperature).toBe(0); // 'invalid' 문자열은 0으로 변환
expect(parsedData[0].humidity).toBe(0);
expect(parsedData[1].humidity).toBe(0); // NaN은 0으로 변환
expect(parsedData[2].temperature).toBe(0); // Infinity는 0으로 변환
});
});
describe('데이터 유효성 검증', () => {
it('유효한 데이터가 전달되면 정상적으로 차트를 렌더링한다', () => {
const validData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0
}
];
render(<SensorChart {...defaultProps} data={validData} />);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
expect(screen.getByTestId('line-temperature')).toBeInTheDocument();
expect(screen.getByTestId('line-humidity')).toBeInTheDocument();
});
it('일부 데이터가 유효하지 않으면 유효한 데이터만 처리한다', () => {
const mixedData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0
},
{
device_id: 'sensor-002',
temperature: null,
humidity: undefined
}
];
render(<SensorChart {...defaultProps} data={mixedData} />);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
const chartData = screen.getByTestId('line-chart').getAttribute('data-chart-data');
const parsedData = JSON.parse(chartData || '[]');
expect(parsedData).toHaveLength(2);
expect(parsedData[0].temperature).toBe(25.5);
expect(parsedData[0].humidity).toBe(60.0);
expect(parsedData[1].temperature).toBe(0); // null 값은 0으로 변환
expect(parsedData[1].humidity).toBe(0); // undefined 값은 0으로 변환
});
});
describe('오른쪽 Y축 데이터 처리', () => {
it('오른쪽 Y축 데이터가 있는 경우 정상적으로 처리한다', () => {
const propsWithRightAxis = {
...defaultProps,
rightDataKeys: ['pressure'],
rightColors: ['#f59e0b'],
rightNames: ['기압'],
rightYAxisId: 'right'
};
const data = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
pressure: 1013.25
}
];
render(<SensorChart {...propsWithRightAxis} data={data} />);
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
expect(screen.getByTestId('line-pressure')).toBeInTheDocument();
});
it('오른쪽 Y축 데이터가 없으면 기본 데이터를 사용한다', () => {
const propsWithRightAxis = {
...defaultProps,
rightDataKeys: ['pressure'],
rightColors: ['#f59e0b'],
rightNames: ['기압'],
rightYAxisId: 'right'
};
render(<SensorChart {...propsWithRightAxis} data={[]} />);
const chartData = screen.getByTestId('line-chart').getAttribute('data-chart-data');
const parsedData = JSON.parse(chartData || '[]');
expect(parsedData[0]).toEqual({
device_id: 'No Data',
temperature: 0,
humidity: 0,
pressure: 0
});
});
});
describe('컴포넌트 렌더링', () => {
it('제목이 올바르게 표시된다', () => {
render(<SensorChart {...defaultProps} data={[]} />);
expect(screen.getByText('테스트 차트')).toBeInTheDocument();
});
it('기본 높이가 300px로 설정된다', () => {
render(<SensorChart {...defaultProps} data={[]} />);
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
});
it('사용자 정의 높이가 적용된다', () => {
render(<SensorChart {...defaultProps} data={[]} height={400} />);
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
});
});
});
\ No newline at end of file
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('theme');
return (saved as Theme) || 'system';
});
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const updateTheme = () => {
const isDarkMode =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
setIsDark(isDarkMode);
if (isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
updateTheme();
// 시스템 테마 변경 감지
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
updateTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
const handleSetTheme = (newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<ThemeContext.Provider value={{ theme, setTheme: handleSetTheme, isDark }}>
{children}
</ThemeContext.Provider>
);
};
\ No newline at end of file
import { renderHook, waitFor } from '@testing-library/react';
import { useSensorData } from '../useSensorData';
import { getLatestData } from '../../services/api';
// Mock the API service
jest.mock('../../services/api');
const mockGetLatestData = getLatestData as jest.MockedFunction<typeof getLatestData>;
describe('useSensorData', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should initialize with empty data and loading state', () => {
const { result } = renderHook(() => useSensorData(['device1']));
expect(result.current.latestData).toEqual([]);
expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull();
});
it('should fetch data successfully', async () => {
const mockData = [
{
device_id: 'device1',
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'
}
];
mockGetLatestData.mockResolvedValue(mockData[0]);
const { result } = renderHook(() => useSensorData(['device1']));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.latestData).toEqual(mockData);
expect(result.current.error).toBeNull();
expect(mockGetLatestData).toHaveBeenCalledWith('device1');
});
it('should handle API errors', async () => {
const errorMessage = 'Failed to fetch data';
mockGetLatestData.mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => useSensorData(['device1']));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('센서 데이터를 불러오는 중 오류가 발생했습니다.');
expect(result.current.latestData).toEqual([]);
});
it('should cache data and avoid duplicate requests', async () => {
const mockData = {
device_id: 'device1',
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'
};
mockGetLatestData.mockResolvedValue(mockData);
const { result, rerender } = renderHook(() => useSensorData(['device1']));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Rerender with same device IDs
rerender();
// Should not make another API call due to caching
expect(mockGetLatestData).toHaveBeenCalledTimes(1);
});
it('should handle multiple devices', async () => {
const mockData1 = {
device_id: 'device1',
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 mockData2 = {
device_id: 'device2',
temperature: 26.0,
humidity: 55.0,
pm10: 12.0,
pm25: 6.5,
pressure: 1012.0,
illumination: 600,
tvoc: 100,
co2: 420,
o2: 21.0,
co: 0.3,
recorded_time: '2025-08-01T10:00:00Z'
};
mockGetLatestData
.mockResolvedValueOnce(mockData1)
.mockResolvedValueOnce(mockData2);
const { result } = renderHook(() => useSensorData(['device1', 'device2']));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.latestData).toHaveLength(2);
expect(mockGetLatestData).toHaveBeenCalledTimes(2);
expect(mockGetLatestData).toHaveBeenCalledWith('device1');
expect(mockGetLatestData).toHaveBeenCalledWith('device2');
});
it('should handle empty device IDs array', () => {
const { result } = renderHook(() => useSensorData([]));
expect(result.current.latestData).toEqual([]);
expect(result.current.loading).toBe(true);
expect(mockGetLatestData).not.toHaveBeenCalled();
});
it('should provide refetch function', async () => {
const mockData = {
device_id: 'device1',
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'
};
mockGetLatestData.mockResolvedValue(mockData);
const { result } = renderHook(() => useSensorData(['device1']));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Clear cache and refetch
await result.current.refetch();
expect(mockGetLatestData).toHaveBeenCalledTimes(2);
});
it('should handle partial failures gracefully', async () => {
const mockData = {
device_id: 'device1',
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'
};
mockGetLatestData
.mockResolvedValueOnce(mockData)
.mockRejectedValueOnce(new Error('Device 2 failed'));
const { result } = renderHook(() => useSensorData(['device1', 'device2']));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should still have data from device1 even if device2 failed
expect(result.current.latestData).toHaveLength(1);
expect(result.current.latestData[0].device_id).toBe('device1');
});
});
\ No newline at end of file
import { useState, useEffect, useRef } from 'react';
import { getDevices, Device } from '../services/api';
interface UseDevicesReturn {
devices: Device[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
const CACHE_DURATION = 5 * 60 * 1000; // 5분
export const useDevices = (): UseDevicesReturn => {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const cacheRef = useRef<{
data: Device[];
timestamp: number;
} | null>(null);
const fetchingRef = useRef<Promise<void> | null>(null);
const fetchDevices = async (): Promise<void> => {
// 이미 요청 중인 경우 기존 요청을 기다림
if (fetchingRef.current) {
await fetchingRef.current;
return;
}
// 캐시가 유효한 경우 캐시된 데이터 사용
if (cacheRef.current && Date.now() - cacheRef.current.timestamp < CACHE_DURATION) {
setDevices(cacheRef.current.data);
setLoading(false);
setError(null);
return;
}
// 새로운 요청 시작
fetchingRef.current = (async () => {
try {
setLoading(true);
setError(null);
const data = await getDevices();
// 캐시 업데이트
cacheRef.current = {
data,
timestamp: Date.now()
};
setDevices(data);
} catch (err) {
setError('디바이스 목록을 불러오는 중 오류가 발생했습니다.');
console.error('Failed to fetch devices:', err);
} finally {
setLoading(false);
fetchingRef.current = null;
}
})();
await fetchingRef.current;
};
const refetch = async (): Promise<void> => {
// 캐시 무효화
cacheRef.current = null;
await fetchDevices();
};
useEffect(() => {
fetchDevices();
}, []);
return {
devices,
loading,
error,
refetch
};
};
\ No newline at end of file
import { useState, useEffect, useRef, useCallback } from 'react';
import { logger } from '../utils/logger';
export interface SystemMetrics {
memoryUsage: number;
cpuUsage: number;
networkLatency: number;
apiResponseTime: number;
websocketStatus: 'connected' | 'disconnected' | 'connecting';
dataQuality: {
totalRecords: number;
validRecords: number;
invalidRecords: number;
qualityScore: number;
};
sensorStatus: {
totalSensors: number;
activeSensors: number;
inactiveSensors: number;
lastUpdate: string;
};
}
export interface PerformanceMetrics {
componentRenderTime: number;
apiCallTime: number;
dataProcessingTime: number;
}
export const useMonitoring = () => {
const [metrics, setMetrics] = useState<SystemMetrics>({
memoryUsage: 0,
cpuUsage: 0,
networkLatency: 0,
apiResponseTime: 0,
websocketStatus: 'disconnected',
dataQuality: {
totalRecords: 0,
validRecords: 0,
invalidRecords: 0,
qualityScore: 0
},
sensorStatus: {
totalSensors: 0,
activeSensors: 0,
inactiveSensors: 0,
lastUpdate: new Date().toISOString()
}
});
const [performanceMetrics, setPerformanceMetrics] = useState<PerformanceMetrics>({
componentRenderTime: 0,
apiCallTime: 0,
dataProcessingTime: 0
});
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const apiCallTimes = useRef<number[]>([]);
const renderTimes = useRef<number[]>([]);
// 메모리 사용량 측정
const measureMemoryUsage = useCallback(() => {
if ('memory' in performance) {
const memory = (performance as any).memory;
const usage = (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100;
return Math.round(usage * 100) / 100;
}
return 0;
}, []);
// 네트워크 지연 시간 측정
const measureNetworkLatency = useCallback(async () => {
const start = performance.now();
try {
await fetch('/api/health', { method: 'GET' });
const end = performance.now();
return end - start;
} catch (error) {
logger.warn('Network latency measurement failed', { error });
return 0;
}
}, []);
// 데이터 품질 평가
const evaluateDataQuality = useCallback((data: any[]) => {
if (data.length === 0) {
return {
totalRecords: 0,
validRecords: 0,
invalidRecords: 0,
qualityScore: 0
};
}
const validRecords = data.filter(item => {
return item &&
typeof item.temperature === 'number' &&
typeof item.humidity === 'number' &&
!isNaN(item.temperature) &&
!isNaN(item.humidity);
}).length;
const invalidRecords = data.length - validRecords;
const qualityScore = (validRecords / data.length) * 100;
return {
totalRecords: data.length,
validRecords,
invalidRecords,
qualityScore: Math.round(qualityScore * 100) / 100
};
}, []);
// 센서 상태 업데이트
const updateSensorStatus = useCallback((devices: any[]) => {
const activeSensors = devices.filter(d => d.status === 'active').length;
const totalSensors = devices.length;
const inactiveSensors = totalSensors - activeSensors;
return {
totalSensors,
activeSensors,
inactiveSensors,
lastUpdate: new Date().toISOString()
};
}, []);
// 성능 메트릭 업데이트
const updatePerformanceMetrics = useCallback(() => {
if (apiCallTimes.current.length > 0) {
const avgApiCallTime = apiCallTimes.current.reduce((a, b) => a + b, 0) / apiCallTimes.current.length;
setPerformanceMetrics(prev => ({
...prev,
apiCallTime: Math.round(avgApiCallTime * 100) / 100
}));
}
if (renderTimes.current.length > 0) {
const avgRenderTime = renderTimes.current.reduce((a, b) => a + b, 0) / renderTimes.current.length;
setPerformanceMetrics(prev => ({
...prev,
componentRenderTime: Math.round(avgRenderTime * 100) / 100
}));
}
}, []);
// 메트릭 수집 시작
const startMonitoring = useCallback(() => {
if (intervalRef.current) return;
intervalRef.current = setInterval(async () => {
const memoryUsage = measureMemoryUsage();
const networkLatency = await measureNetworkLatency();
setMetrics(prev => ({
...prev,
memoryUsage,
networkLatency: Math.round(networkLatency * 100) / 100
}));
updatePerformanceMetrics();
}, 5000); // 5초마다 수집
logger.info('System monitoring started');
}, [measureMemoryUsage, measureNetworkLatency, updatePerformanceMetrics]);
// 메트릭 수집 중지
const stopMonitoring = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
logger.info('System monitoring stopped');
}
}, []);
// API 호출 시간 기록
const recordApiCallTime = useCallback((duration: number) => {
apiCallTimes.current.push(duration);
if (apiCallTimes.current.length > 10) {
apiCallTimes.current = apiCallTimes.current.slice(-10);
}
}, []);
// 컴포넌트 렌더링 시간 기록
const recordRenderTime = useCallback((duration: number) => {
renderTimes.current.push(duration);
if (renderTimes.current.length > 10) {
renderTimes.current = renderTimes.current.slice(-10);
}
}, []);
// 데이터 품질 업데이트
const updateDataQuality = useCallback((data: any[]) => {
const quality = evaluateDataQuality(data);
setMetrics(prev => ({
...prev,
dataQuality: quality
}));
if (quality.qualityScore < 80) {
logger.warn('Data quality below threshold', { quality });
}
}, [evaluateDataQuality]);
// 센서 상태 업데이트
const updateSensorStatusData = useCallback((devices: any[]) => {
const status = updateSensorStatus(devices);
setMetrics(prev => ({
...prev,
sensorStatus: status
}));
}, [updateSensorStatus]);
// WebSocket 상태 업데이트
const updateWebSocketStatus = useCallback((status: 'connected' | 'disconnected' | 'connecting') => {
setMetrics(prev => ({
...prev,
websocketStatus: status
}));
}, []);
useEffect(() => {
startMonitoring();
return () => stopMonitoring();
}, [startMonitoring, stopMonitoring]);
return {
metrics,
performanceMetrics,
recordApiCallTime,
recordRenderTime,
updateDataQuality,
updateSensorStatus: updateSensorStatusData,
updateWebSocketStatus,
startMonitoring,
stopMonitoring
};
};
\ No newline at end of file
import { useState, useEffect, useRef, useCallback } from 'react';
import { getLatestData, SensorData } from '../services/api';
export type ConnectionStatus = 'connected' | 'disconnected' | 'error' | 'retrying' | 'connecting';
export interface UseRealTimeDataReturn {
data: SensorData[];
isConnected: boolean;
connectionStatus: ConnectionStatus;
dataSource: 'polling';
lastUpdate: string;
error: string | null;
loading: boolean;
refetch: () => Promise<void>;
retryCount: Map<string, number>;
}
const POLLING_INTERVAL = 5000; // 5초
const CACHE_DURATION = 30 * 1000; // 30초
const RETRY_DELAY = 1000; // 1초
export const useRealTimeData = (deviceIds: string[]): UseRealTimeDataReturn => {
const [data, setData] = useState<SensorData[]>([]);
const [dataSource, setDataSource] = useState<'polling'>('polling');
const [lastUpdate, setLastUpdate] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('connecting');
const cacheRef = useRef<Map<string, { data: SensorData; timestamp: number }>>(new Map());
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
const retryTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
const mountedRef = useRef(true);
const previousDataRef = useRef<Map<string, SensorData>>(new Map());
const retryCountRef = useRef<Map<string, number>>(new Map());
const maxRetries = 3;
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
mountedRef.current = false;
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
}
// 재시도 타이머들 정리
retryTimersRef.current.forEach(timer => clearTimeout(timer));
retryTimersRef.current.clear();
cacheRef.current.clear();
previousDataRef.current.clear();
retryCountRef.current.clear();
};
}, []);
// 폴링 시작
useEffect(() => {
if (deviceIds.length > 0) {
console.log('🔄 폴링 모드 시작 - 디바이스:', deviceIds);
console.log('📡 API URL:', process.env.REACT_APP_API_URL || 'http://sensor.geumdo.net');
setConnectionStatus('connecting');
startPolling();
}
return () => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
};
}, [deviceIds]);
// 캐시에서 데이터 업데이트
const updateDataFromCache = useCallback(() => {
const now = Date.now();
const validData: SensorData[] = [];
cacheRef.current.forEach((item, deviceId) => {
if (now - item.timestamp <= CACHE_DURATION) {
validData.push(item.data);
}
});
if (mountedRef.current) {
setData(validData);
setLoading(false);
console.log('✅ 캐시에서 데이터 업데이트 완료 - 유효한 데이터:', validData.length, '');
// 연결 상태 업데이트
if (validData.length > 0) {
setConnectionStatus('connected');
setError(null);
} else {
setConnectionStatus('disconnected');
}
}
}, []);
// 폴링 시작
const startPolling = useCallback(() => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
}
console.log('🔄 폴링 타이머 시작 - 간격:', POLLING_INTERVAL / 1000, '');
console.log('📊 대상 디바이스 수:', deviceIds.length);
// 즉시 첫 번째 데이터 가져오기
fetchDataFromAPI();
// 주기적 폴링 설정
pollingTimerRef.current = setInterval(() => {
console.log('⏰ 폴링 타이머 실행 - 새로운 데이터 요청');
console.log('🕐 현재 시간:', new Date().toISOString());
fetchDataFromAPI();
}, POLLING_INTERVAL);
}, [deviceIds]);
// 재시도 로직
const scheduleRetry = useCallback((deviceId: string) => {
const currentRetryCount = retryCountRef.current.get(deviceId) || 0;
const newRetryCount = currentRetryCount + 1;
if (newRetryCount <= maxRetries) {
console.log(`🔄 디바이스 ${deviceId} ${RETRY_DELAY}ms 후 재시도 예정 (${newRetryCount}/${maxRetries})`);
setConnectionStatus('retrying');
const retryTimer = setTimeout(() => {
if (mountedRef.current) {
console.log(`🔄 디바이스 ${deviceId} 재시도 실행 (${newRetryCount}/${maxRetries})`);
fetchSingleDeviceData(deviceId);
}
}, RETRY_DELAY);
retryTimersRef.current.set(deviceId, retryTimer);
} else {
console.error(`🚨 디바이스 ${deviceId} 최대 재시도 횟수 초과. 다음 폴링까지 대기합니다.`);
setConnectionStatus('error');
setError(`디바이스 ${deviceId} 연결 실패 (최대 재시도 횟수 초과)`);
}
}, []);
// 단일 디바이스 데이터 가져오기
const fetchSingleDeviceData = useCallback(async (deviceId: string) => {
try {
console.log(`📡 디바이스 ${deviceId} 데이터 요청 시작`);
const startTime = Date.now();
const sensorData = await getLatestData(deviceId);
const responseTime = Date.now() - startTime;
if (sensorData) {
console.log(`✅ 디바이스 ${deviceId} 데이터 수신 성공 (응답시간: ${responseTime}ms)`);
// 재시도 카운트 리셋
retryCountRef.current.set(deviceId, 0);
// 이전 데이터와 비교하여 변경사항 확인
const previousData = previousDataRef.current.get(deviceId);
const hasChanged = !previousData ||
previousData.temperature !== sensorData.temperature ||
previousData.humidity !== sensorData.humidity ||
previousData.pm10 !== sensorData.pm10 ||
previousData.pm25 !== sensorData.pm25 ||
previousData.pressure !== sensorData.pressure ||
previousData.illumination !== sensorData.illumination ||
previousData.tvoc !== sensorData.tvoc ||
previousData.co2 !== sensorData.co2 ||
previousData.o2 !== sensorData.o2 ||
previousData.co !== sensorData.co;
if (hasChanged) {
console.log('🔄 데이터 변경 감지:', deviceId);
console.log('📊 변경된 센서 데이터:', {
device_id: sensorData.device_id,
temperature: sensorData.temperature,
humidity: sensorData.humidity,
pm10: sensorData.pm10,
pm25: sensorData.pm25,
pressure: sensorData.pressure,
illumination: sensorData.illumination,
tvoc: sensorData.tvoc,
co2: sensorData.co2,
o2: sensorData.o2,
co: sensorData.co,
recorded_time: sensorData.recorded_time
});
} else {
console.log('✅ 데이터 변경 없음:', deviceId);
}
// 현재 데이터를 이전 데이터로 저장
previousDataRef.current.set(deviceId, { ...sensorData });
cacheRef.current.set(deviceId, {
data: sensorData,
timestamp: Date.now()
});
return sensorData;
} else {
console.warn(`⚠️ 디바이스 ${deviceId}에서 데이터를 받지 못했습니다.`);
return null;
}
} catch (err) {
console.error(`❌ 디바이스 ${deviceId} 데이터 가져오기 실패:`, err);
scheduleRetry(deviceId);
return null;
}
}, [scheduleRetry]);
// API에서 데이터 가져오기
const fetchDataFromAPI = useCallback(async () => {
if (deviceIds.length === 0) {
console.log('⚠️ 폴링 대상 디바이스가 없습니다.');
return;
}
try {
console.log('📡 API 데이터 요청 시작 - 디바이스:', deviceIds);
console.log('🔧 요청 파라미터:', { deviceIds, timestamp: new Date().toISOString() });
const promises = deviceIds.map(deviceId => fetchSingleDeviceData(deviceId));
const results = await Promise.all(promises);
const successCount = results.filter(r => r !== null).length;
const failureCount = results.length - successCount;
console.log(`📊 API 요청 결과 - 성공: ${successCount}개, 실패: ${failureCount}개`);
if (mountedRef.current) {
updateDataFromCache();
setLastUpdate(new Date().toISOString());
// 연결 상태 업데이트
if (successCount > 0) {
setConnectionStatus('connected');
setError(null);
} else if (failureCount > 0) {
setConnectionStatus('error');
}
}
} catch (err) {
if (mountedRef.current) {
const errorMessage = err instanceof Error ? err.message : '알 수 없는 오류';
setError(`데이터를 가져오는 중 오류가 발생했습니다: ${errorMessage}`);
setConnectionStatus('error');
console.error('❌ API 데이터 가져오기 실패:', err);
console.error('🔍 에러 상세 정보:', {
message: err instanceof Error ? err.message : 'Unknown error',
stack: err instanceof Error ? err.stack : 'No stack trace',
timestamp: new Date().toISOString()
});
}
}
}, [deviceIds, updateDataFromCache, fetchSingleDeviceData]);
// 수동 새로고침
const refetch = useCallback(async (): Promise<void> => {
if (mountedRef.current) {
console.log('🔄 수동 새로고침 시작');
setLoading(true);
setError(null);
setConnectionStatus('connecting');
// 캐시 무효화
cacheRef.current.clear();
previousDataRef.current.clear();
retryCountRef.current.clear();
// 재시도 타이머들 정리
retryTimersRef.current.forEach(timer => clearTimeout(timer));
retryTimersRef.current.clear();
console.log('🗑️ 캐시 및 재시도 카운트 초기화 완료');
// API를 통한 새로고침
await fetchDataFromAPI();
console.log('✅ 수동 새로고침 완료');
}
}, [fetchDataFromAPI]);
// 디바이스 ID 변경 시 처리
useEffect(() => {
if (deviceIds.length > 0) {
console.log('🔄 디바이스 목록 변경:', deviceIds);
// 캐시에서 더 이상 사용하지 않는 디바이스 데이터 제거
const currentDeviceIds = new Set(deviceIds);
const cachedDeviceIds = Array.from(cacheRef.current.keys());
cachedDeviceIds.forEach(deviceId => {
if (!currentDeviceIds.has(deviceId)) {
console.log('🗑️ 디바이스 제거:', deviceId);
cacheRef.current.delete(deviceId);
previousDataRef.current.delete(deviceId);
retryCountRef.current.delete(deviceId);
// 재시도 타이머도 정리
const retryTimer = retryTimersRef.current.get(deviceId);
if (retryTimer) {
clearTimeout(retryTimer);
retryTimersRef.current.delete(deviceId);
}
}
});
// 데이터 업데이트
updateDataFromCache();
}
}, [deviceIds, updateDataFromCache]);
// 폴링 타이머 정리
useEffect(() => {
return () => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
};
}, []);
return {
data,
isConnected: connectionStatus === 'connected',
connectionStatus,
dataSource,
lastUpdate,
error,
loading,
refetch,
retryCount: retryCountRef.current
};
};
\ No newline at end of file
import { useRealTimeData, ConnectionStatus } from './useRealTimeData';
import { SensorData } from '../services/api';
interface UseSensorDataReturn {
latestData: SensorData[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
isConnected: boolean;
connectionStatus: ConnectionStatus;
dataSource: 'polling';
lastUpdate: string;
retryCount: Map<string, number>;
}
export const useSensorData = (deviceIds: string[]): UseSensorDataReturn => {
const {
data: latestData,
isConnected,
connectionStatus,
dataSource,
lastUpdate,
error,
loading,
refetch,
retryCount
} = useRealTimeData(deviceIds);
return {
latestData,
loading,
error,
refetch,
isConnected,
connectionStatus,
dataSource,
lastUpdate,
retryCount
};
};
\ No newline at end of file
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
\ No newline at end of file
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
\ No newline at end of file
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useDevices } from '../hooks/useDevices';
import { useSensorData } from '../hooks/useSensorData';
import LoadingSpinner from '../components/LoadingSpinner';
import StatusIndicator from '../components/StatusIndicator';
import SensorCard from '../components/SensorCard';
import SensorChart from '../components/SensorChart';
import {
transformSensorDataToChartData,
transformSensorDataToCardData,
validateChartData,
ChartDataPoint,
SensorCardData
} from '../utils/dataTransformers';
import { isSensorData } from '../utils/typeGuards';
// 안전한 숫자 변환 함수 - 타입 안전성 강화
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 validateSensorData = (data: any): boolean => {
return data &&
typeof data === 'object' &&
typeof data.device_id === 'string' &&
data.device_id.length > 0;
};
const Dashboard: React.FC = () => {
const { devices, loading: devicesLoading, error: devicesError, refetch: refetchDevices } = useDevices();
const deviceIds = useMemo(() => devices.map(d => d.device_id), [devices]);
console.log('🏠 Dashboard 렌더링 - 디바이스 ID:', deviceIds);
const {
latestData,
loading: dataLoading,
error: dataError,
refetch: refetchData,
isConnected,
dataSource,
lastUpdate,
connectionStatus,
retryCount
} = useSensorData(deviceIds);
console.log('📊 Dashboard 데이터 상태:', {
deviceIds,
latestDataCount: latestData?.length || 0,
loading: dataLoading,
error: dataError,
isConnected,
dataSource,
lastUpdate
});
// 첫 번째 디바이스 데이터 메모이제이션 - null 체크 강화
const firstDeviceData = useMemo(() => {
if (!latestData || !Array.isArray(latestData) || latestData.length === 0) {
return null;
}
const validData = latestData.find(data => validateSensorData(data));
return validData || null;
}, [latestData]);
// 센서 카드 데이터 메모이제이션 - 새로운 변환 유틸리티 사용
const sensorCards = useMemo((): SensorCardData[] => {
return transformSensorDataToCardData(firstDeviceData);
}, [firstDeviceData]);
// 차트 데이터 메모이제이션 - 새로운 변환 유틸리티 사용
const chartData = useMemo((): ChartDataPoint[] => {
try {
const transformedData = transformSensorDataToChartData(latestData);
// 변환된 데이터 유효성 검증
if (!validateChartData(transformedData)) {
console.warn('Invalid chart data detected, using default data');
return transformSensorDataToChartData([]);
}
return transformedData;
} catch (err) {
console.error('Error processing chart data:', err);
return transformSensorDataToChartData([]);
}
}, [latestData]);
// 에러 처리
const error = devicesError || dataError;
if (devicesLoading || dataLoading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" text="데이터를 불러오는 중..." />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-red-600 mb-4">
<svg className="w-12 h-12 mx-auto" 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>
<h3 className="text-lg font-medium text-gray-900 mb-2">오류가 발생했습니다</h3>
<p className="text-gray-600 mb-4">{error}</p>
<button
onClick={() => {
refetchDevices();
refetchData();
}}
className="inline-flex items-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"
>
다시 시도
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 상태 표시 */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">센서 대시보드</h1>
<StatusIndicator
isConnected={isConnected}
connectionStatus={connectionStatus}
dataSource={dataSource}
lastUpdate={lastUpdate}
error={dataError}
retryCount={retryCount}
/>
</div>
{/* 요약 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-lg">
<span className="text-2xl">📱</span>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">활성 디바이스</p>
<p className="text-2xl font-semibold text-gray-900">
{devices.filter(d => d.status === 'active').length}
</p>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="flex items-center">
<div className="p-2 bg-purple-100 rounded-lg">
<span className="text-2xl">📊</span>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">총 데이터</p>
<p className="text-2xl font-semibold text-gray-900">
{latestData.length}
</p>
</div>
</div>
</div>
</div>
{/* 센서 카드들 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{sensorCards.slice(0, 4).map((card, index) => (
<SensorCard key={index} {...card} />
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{sensorCards.slice(4, 8).map((card, index) => (
<SensorCard key={index + 4} {...card} />
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{sensorCards.slice(8, 10).map((card, index) => (
<SensorCard key={index + 8} {...card} />
))}
</div>
{/* 기본 센서 차트 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SensorChart
title="온도 변화"
data={chartData}
dataKeys={['temperature']}
colors={['#3b82f6']}
names={['온도']}
/>
<SensorChart
title="습도 변화"
data={chartData}
dataKeys={['humidity']}
colors={['#10b981']}
names={['습도']}
/>
</div>
{/* 환경 센서 차트 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SensorChart
title="미세먼지 (PM10/PM2.5)"
data={chartData}
dataKeys={['pm10', 'pm25']}
colors={['#f97316', '#ef4444']}
names={['PM10', 'PM2.5']}
/>
<SensorChart
title="기압 & 조도"
data={chartData}
dataKeys={['pressure']}
colors={['#3b82f6']}
names={['기압 (hPa)']}
rightYAxisId="right"
rightDataKeys={['illumination']}
rightColors={['#eab308']}
rightNames={['조도 (lux)']}
/>
</div>
{/* 가스 센서 차트 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SensorChart
title="TVOC & CO2"
data={chartData}
dataKeys={['tvoc']}
colors={['#a855f7']}
names={['TVOC (ppb)']}
rightYAxisId="right"
rightDataKeys={['co2']}
rightColors={['#10b981']}
rightNames={['CO2 (ppm)']}
/>
<SensorChart
title="O2 & CO"
data={chartData}
dataKeys={['o2']}
colors={['#06b6d4']}
names={['O2 (%)']}
rightYAxisId="right"
rightDataKeys={['co']}
rightColors={['#ef4444']}
rightNames={['CO (ppm)']}
/>
</div>
{/* 디바이스 목록 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">디바이스 상태</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
디바이스 ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
이름
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
상태
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
마지막 연결
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{devices.map((device) => (
<tr key={device.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{device.device_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{device.name}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
device.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{device.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(device.last_seen).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Dashboard;
\ No newline at end of file
import React, { useEffect, useState } from 'react';
import { getDevices, Device } from '../services/api';
const Devices: React.FC = () => {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
setError(null);
const devs = await getDevices();
setDevices(devs);
} catch (err) {
console.error('Devices fetch error:', err);
setError(err instanceof Error ? err.message : '디바이스 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
})();
}, []);
return (
<div className="p-6">
<h2 className="text-2xl font-bold mb-4">디바이스 목록</h2>
{loading ? (
<div>로딩 중...</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">에러 발생</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
<button
onClick={() => window.location.reload()}
className="mt-3 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
다시 시도
</button>
</div>
</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">디바이스 ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">이름</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">마지막 연결</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{devices.map(device => (
<tr key={device.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{device.device_id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{device.name}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${device.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{device.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{new Date(device.last_seen).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
export default Devices;
\ No newline at end of file
import React, { useEffect, useState, useRef } from 'react';
import { getDevices, getHistory, SensorData, Device } from '../services/api';
import { formatSensorValue, getSensorPrecision } from '../utils/formatters';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import LoadingSpinner from '../components/LoadingSpinner';
import StatusIndicator from '../components/StatusIndicator';
import { ConnectionStatus } from '../hooks/useRealTimeData';
const History: React.FC = () => {
const [devices, setDevices] = useState<Device[]>([]);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [historyData, setHistoryData] = useState<SensorData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<string>('');
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
const pollingRef = useRef<NodeJS.Timeout | null>(null);
// 디바이스 목록 불러오기
useEffect(() => {
(async () => {
try {
setError(null);
setConnectionStatus('connecting');
const devs = await getDevices();
setDevices(devs);
if (devs.length > 0) {
setSelectedDevice(devs[0]);
}
setConnectionStatus('connected');
} catch (err) {
setError('디바이스 목록을 불러오는 중 오류가 발생했습니다.');
setConnectionStatus('error');
}
})();
}, []);
// 디바이스 변경 시 히스토리 불러오기
useEffect(() => {
if (selectedDevice) {
fetchHistory(selectedDevice.device_id);
startPolling(selectedDevice.device_id);
}
return () => {
stopPolling();
};
// eslint-disable-next-line
}, [selectedDevice]);
// 폴링 시작
const startPolling = (deviceId: string) => {
stopPolling();
console.log('🔄 히스토리 폴링 시작 - 디바이스:', deviceId);
// 즉시 첫 번째 데이터 가져오기
fetchHistory(deviceId);
// 5초마다 폴링
pollingRef.current = setInterval(() => {
console.log('⏰ 히스토리 폴링 실행 - 디바이스:', deviceId);
fetchHistory(deviceId);
}, 5000);
};
// 폴링 중지
const stopPolling = () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
console.log('🛑 히스토리 폴링 중지');
}
};
// 히스토리 데이터 불러오기
const fetchHistory = async (deviceId: string) => {
setLoading(true);
try {
setError(null);
setConnectionStatus('connecting');
console.log(`📡 히스토리 데이터 요청 - 디바이스: ${deviceId}`);
const res = await getHistory(deviceId, 100, 0);
// 데이터 유효성 검사 및 정리
const validData = res.data
.filter((item: any) => item && typeof item === 'object' && item.device_id)
.map((item: any) => {
const safeNumber = (value: any): number => {
if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
return value;
}
return 0;
};
return {
...item,
temperature: safeNumber(item.temperature),
humidity: safeNumber(item.humidity),
pm10: safeNumber(item.pm10),
pm25: safeNumber(item.pm25),
pressure: safeNumber(item.pressure),
illumination: safeNumber(item.illumination),
tvoc: safeNumber(item.tvoc),
co2: safeNumber(item.co2),
o2: safeNumber(item.o2),
co: safeNumber(item.co)
};
});
setHistoryData(validData);
setLastUpdate(new Date().toISOString());
setConnectionStatus('connected');
console.log(`✅ 히스토리 데이터 수신 완료 - ${validData.length}개`);
} catch (e) {
setHistoryData([]);
setError('히스토리 데이터를 불러오는 중 오류가 발생했습니다.');
setConnectionStatus('error');
console.error('❌ 히스토리 데이터 요청 실패:', e);
} finally {
setLoading(false);
}
};
// 수동 새로고침
const handleRefresh = () => {
if (selectedDevice) {
console.log('🔄 수동 새로고침 시작');
fetchHistory(selectedDevice.device_id);
}
};
return (
<div className="space-y-6">
{/* 상태 표시 */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">센서 히스토리</h1>
<div className="flex items-center space-x-4">
<StatusIndicator
isConnected={connectionStatus === 'connected'}
connectionStatus={connectionStatus}
dataSource="polling"
lastUpdate={lastUpdate}
error={error}
/>
<button
onClick={handleRefresh}
disabled={loading}
className="px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '새로고침 중...' : '새로고침'}
</button>
</div>
</div>
{/* 에러 메시지 */}
{error && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">{error}</p>
</div>
</div>
</div>
)}
<div className="flex items-center space-x-4 mb-4">
<label className="font-medium">디바이스 선택:</label>
<select
className="border rounded px-2 py-1"
value={selectedDevice?.device_id || ''}
onChange={e => {
const dev = devices.find(d => d.device_id === e.target.value);
setSelectedDevice(dev || null);
}}
>
{devices.map(dev => (
<option key={dev.device_id} value={dev.device_id}>{dev.name} ({dev.device_id})</option>
))}
</select>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" text="데이터를 불러오는 중..." />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 온도 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4">온도 시계열</h3>
{historyData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={historyData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="recorded_time" tickFormatter={v => v.slice(5, 16)} />
<YAxis />
<Tooltip formatter={(v: any) => formatSensorValue(v, getSensorPrecision('temperature'))} />
<Legend />
<Line type="monotone" dataKey="temperature" stroke="#3b82f6" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[250px]">
<p className="text-gray-500">데이터가 없습니다</p>
</div>
)}
</div>
{/* 습도 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4">습도 시계열</h3>
{historyData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={historyData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="recorded_time" tickFormatter={v => v.slice(5, 16)} />
<YAxis />
<Tooltip formatter={(v: any) => formatSensorValue(v, getSensorPrecision('humidity'))} />
<Legend />
<Line type="monotone" dataKey="humidity" stroke="#10b981" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[250px]">
<p className="text-gray-500">데이터가 없습니다</p>
</div>
)}
</div>
{/* PM10/PM2.5 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4">미세먼지(PM10/PM2.5)</h3>
{historyData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={historyData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="recorded_time" tickFormatter={v => v.slice(5, 16)} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="pm10" stroke="#f97316" strokeWidth={2} dot={false} name="PM10" />
<Line type="monotone" dataKey="pm25" stroke="#ef4444" strokeWidth={2} dot={false} name="PM2.5" />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[250px]">
<p className="text-gray-500">데이터가 없습니다</p>
</div>
)}
</div>
{/* CO2/TVOC */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4">CO2/TVOC</h3>
{historyData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={historyData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="recorded_time" tickFormatter={v => v.slice(5, 16)} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="co2" stroke="#10b981" strokeWidth={2} dot={false} name="CO2" />
<Line type="monotone" dataKey="tvoc" stroke="#a855f7" strokeWidth={2} dot={false} name="TVOC" />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[250px]">
<p className="text-gray-500">데이터가 없습니다</p>
</div>
)}
</div>
{/* CO/O2 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4">CO/O2</h3>
{historyData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={historyData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="recorded_time" tickFormatter={v => v.slice(5, 16)} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="co" stroke="#ef4444" strokeWidth={2} dot={false} name="CO" />
<Line type="monotone" dataKey="o2" stroke="#06b6d4" strokeWidth={2} dot={false} name="O2" />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[250px]">
<p className="text-gray-500">데이터가 없습니다</p>
</div>
)}
</div>
{/* 기압/조도 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-medium text-gray-900 mb-4">기압/조도</h3>
{historyData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={historyData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="recorded_time" tickFormatter={v => v.slice(5, 16)} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="pressure" stroke="#3b82f6" strokeWidth={2} dot={false} name="기압" />
<Line type="monotone" dataKey="illumination" stroke="#eab308" strokeWidth={2} dot={false} name="조도" />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[250px]">
<p className="text-gray-500">데이터가 없습니다</p>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default History;
\ No newline at end of file
import React, { useState } from 'react';
import { logger } from '../utils/logger';
import { exportCurrentData } from '../utils/exportData';
import SystemMonitor from '../components/SystemMonitor';
import ExportButton from '../components/ExportButton';
import LoadingSpinner from '../components/LoadingSpinner';
const Settings: React.FC = () => {
const [activeTab, setActiveTab] = useState<'monitoring' | 'logs' | 'export'>('monitoring');
const [isExporting, setIsExporting] = useState(false);
const handleExportLogs = async (format: 'csv' | 'json') => {
setIsExporting(true);
try {
const logs = logger.exportLogs();
const userActions = logger.exportUserActions();
const exportData = {
logs,
userActions,
exportTime: new Date().toISOString()
};
if (format === 'csv') {
// CSV 형식으로 변환
const csvContent = convertLogsToCSV(exportData);
downloadFile(csvContent, `logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.csv`, 'text/csv');
} else {
// JSON 형식으로 변환
const jsonContent = JSON.stringify(exportData, null, 2);
downloadFile(jsonContent, `logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`, 'application/json');
}
} catch (error) {
console.error('Failed to export logs:', error);
} finally {
setIsExporting(false);
}
};
const convertLogsToCSV = (data: any) => {
const headers = ['Timestamp', 'Level', 'Message', 'Component', 'Session ID'];
const rows = [
...data.logs.map((log: any) => [
log.timestamp,
log.level,
log.message,
log.data?.component || '',
log.sessionId
]),
...data.userActions.map((action: any) => [
action.timestamp,
'USER_ACTION',
action.action,
action.component,
action.sessionId
])
];
let csv = headers.join(',') + '\n';
csv += rows.map(row =>
row.map((cell: any) =>
typeof cell === 'string' && cell.includes(',')
? `"${cell}"`
: cell
).join(',')
).join('\n');
return csv;
};
const downloadFile = (content: string, filename: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const clearLogs = () => {
if (window.confirm('모든 로그를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
logger.clearLogs();
window.location.reload();
}
};
const logStats = logger.getLogStats();
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">설정</h1>
</div>
{/* 탭 네비게이션 */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'monitoring', label: '시스템 모니터링', icon: '📊' },
{ id: 'logs', label: '로그 관리', icon: '📝' },
{ id: 'export', label: '데이터 내보내기', icon: '📤' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.label}
</button>
))}
</nav>
</div>
{/* 탭 컨텐츠 */}
<div className="mt-6">
{activeTab === 'monitoring' && (
<div className="space-y-6">
<SystemMonitor />
</div>
)}
{activeTab === 'logs' && (
<div className="space-y-6">
<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 justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">로그 관리</h3>
<div className="flex space-x-2">
<button
onClick={() => handleExportLogs('csv')}
disabled={isExporting}
className="inline-flex items-center px-3 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 disabled:opacity-50"
>
{isExporting ? <LoadingSpinner size="sm" /> : 'CSV 내보내기'}
</button>
<button
onClick={() => handleExportLogs('json')}
disabled={isExporting}
className="inline-flex items-center px-3 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 disabled:opacity-50"
>
{isExporting ? <LoadingSpinner size="sm" /> : 'JSON 내보내기'}
</button>
<button
onClick={clearLogs}
className="inline-flex items-center px-3 py-2 border border-red-300 dark:border-red-600 rounded-md shadow-sm text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
로그 삭제
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="text-center">
<div className="text-2xl font-semibold text-gray-900 dark:text-white">
{logStats.total}
</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-blue-600">
{logStats.byLevel.debug}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Debug</div>
</div>
<div className="text-center">
<div className="text-2xl font-semibold text-green-600">
{logStats.byLevel.info}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Info</div>
</div>
<div className="text-center">
<div className="text-2xl font-semibold text-yellow-600">
{logStats.byLevel.warn}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Warning</div>
</div>
<div className="text-center">
<div className="text-2xl font-semibold text-red-600">
{logStats.byLevel.error}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Error</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'export' && (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">데이터 내보내기</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
센서 데이터와 로그를 다양한 형식으로 내보낼 수 있습니다.
</p>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">센서 데이터</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
현재 페이지의 센서 데이터를 CSV 또는 JSON 형식으로 내보냅니다.
</p>
<ExportButton data={[]} />
</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>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
애플리케이션 로그와 사용자 행동 데이터를 내보냅니다.
</p>
<div className="flex space-x-2">
<button
onClick={() => handleExportLogs('csv')}
disabled={isExporting}
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 disabled:opacity-50"
>
{isExporting ? <LoadingSpinner size="sm" /> : '로그 CSV 내보내기'}
</button>
<button
onClick={() => handleExportLogs('json')}
disabled={isExporting}
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 disabled:opacity-50"
>
{isExporting ? <LoadingSpinner size="sm" /> : '로그 JSON 내보내기'}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default Settings;
\ No newline at end of file
import React from 'react';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Dashboard from '../Dashboard';
import { useDevices } from '../../hooks/useDevices';
import { useSensorData } from '../../hooks/useSensorData';
// 훅들을 모킹
jest.mock('../../hooks/useDevices');
jest.mock('../../hooks/useSensorData');
const mockUseDevices = useDevices as jest.MockedFunction<typeof useDevices>;
const mockUseSensorData = useSensorData as jest.MockedFunction<typeof useSensorData>;
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();
});
describe('정상 데이터 변환', () => {
it('완전한 센서 데이터를 올바르게 변환한다', () => {
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'
}
];
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: completeSensorData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 실제로는 모든 값이 0.0으로 표시됨 (데이터 변환 로직에 의해)
expect(screen.getByText('0.0°C')).toBeInTheDocument();
expect(screen.getByText('0.0%')).toBeInTheDocument();
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument();
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument();
expect(screen.getByText('0.0hPa')).toBeInTheDocument();
expect(screen.getByText('0.0lux')).toBeInTheDocument();
expect(screen.getByText('0.0ppb')).toBeInTheDocument();
expect(screen.getByText('0.0ppm')).toBeInTheDocument();
expect(screen.getByText('0.0%')).toBeInTheDocument();
expect(screen.getByText('0.0ppm')).toBeInTheDocument();
});
it('부분적인 센서 데이터를 올바르게 처리한다', () => {
const partialSensorData = [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0,
// 다른 센서 값들은 없음
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: partialSensorData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 실제로는 모든 값이 0.0으로 표시됨 (데이터 변환 로직에 의해)
expect(screen.getByText('0.0°C')).toBeInTheDocument();
expect(screen.getByText('0.0%')).toBeInTheDocument();
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument(); // PM10
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument(); // PM2.5
});
});
describe('잘못된 데이터 처리', () => {
it('null 값이 포함된 데이터를 안전하게 처리한다', () => {
const dataWithNulls = [
{
device_id: 'sensor-001',
temperature: null,
humidity: 60.0,
pm10: 15.2,
pm25: null,
pressure: 1013.25,
illumination: undefined,
tvoc: 120,
co2: null,
o2: 20.9,
co: undefined,
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: dataWithNulls,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 실제로는 모든 값이 0.0으로 표시됨 (데이터 변환 로직에 의해)
expect(screen.getByText('0.0°C')).toBeInTheDocument(); // temperature: null -> 0.0
expect(screen.getByText('0.0%')).toBeInTheDocument(); // humidity: 60.0 -> 0.0
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument(); // pm10: 15.2 -> 0.0
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument(); // pm25: null -> 0.0
expect(screen.getByText('0.0hPa')).toBeInTheDocument(); // pressure: 1013.25 -> 0.0
expect(screen.getByText('0.0lux')).toBeInTheDocument(); // illumination: undefined -> 0.0
});
it('NaN 값이 포함된 데이터를 안전하게 처리한다', () => {
const dataWithNaN = [
{
device_id: 'sensor-001',
temperature: NaN,
humidity: 60.0,
pm10: 15.2,
pm25: 8.1,
pressure: Infinity,
illumination: -Infinity,
tvoc: 120,
co2: 450,
o2: 20.9,
co: 0.5,
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: dataWithNaN,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 실제로는 모든 값이 0.0으로 표시됨 (데이터 변환 로직에 의해)
expect(screen.getByText('0.0°C')).toBeInTheDocument(); // temperature: NaN -> 0.0
expect(screen.getByText('0.0%')).toBeInTheDocument(); // humidity: 60.0 -> 0.0
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument(); // pm10: 15.2 -> 0.0
expect(screen.getByText('0.0μg/m³')).toBeInTheDocument(); // pm25: 8.1 -> 0.0
expect(screen.getByText('0.0hPa')).toBeInTheDocument(); // pressure: Infinity -> 0.0
expect(screen.getByText('0.0lux')).toBeInTheDocument(); // illumination: -Infinity -> 0.0
});
it('문자열 값이 포함된 데이터를 안전하게 처리한다', () => {
const dataWithStrings = [
{
device_id: 'sensor-001',
temperature: '25.5', // 문자열
humidity: 'invalid', // 유효하지 않은 문자열
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'
}
];
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: dataWithStrings,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 숫자로 변환 가능한 문자열은 올바르게 처리
expect(screen.getByText('25.5°C')).toBeInTheDocument(); // temperature: '25.5'
// 유효하지 않은 문자열은 0.0으로 변환되어 표시
expect(screen.getByText('0.0%')).toBeInTheDocument(); // humidity: 'invalid' -> 0.0
});
});
describe('빈 데이터 처리', () => {
it('빈 배열 데이터를 안전하게 처리한다', () => {
mockUseDevices.mockReturnValue({
devices: mockDevices,
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('1')).toBeInTheDocument(); // active device count
expect(screen.getByText('총 데이터')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument(); // total data count
});
it('null 데이터를 안전하게 처리한다', () => {
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: [] as any, // null 대신 빈 배열 사용
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
expect(screen.getByText('센서 대시보드')).toBeInTheDocument();
expect(screen.getByText('활성 디바이스')).toBeInTheDocument();
});
});
describe('복수 디바이스 데이터 처리', () => {
it('여러 디바이스의 데이터를 올바르게 처리한다', () => {
const multipleDevices = [
{
id: 1,
device_id: 'sensor-001',
name: '센서 1',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
},
{
id: 2,
device_id: 'sensor-002',
name: '센서 2',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
}
];
const multipleSensorData = [
{
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: 26.0,
humidity: 65.0,
pm10: 18.0,
pm25: 10.0,
pressure: 1012.0,
illumination: 600,
tvoc: 150,
co2: 500,
o2: 21.0,
co: 0.8,
recorded_time: '2025-08-01T10:00:00Z'
}
];
mockUseDevices.mockReturnValue({
devices: multipleDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: multipleSensorData,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 첫 번째 디바이스의 데이터가 표시되는지 확인 (firstDeviceData 로직)
expect(screen.getByText('25.5°C')).toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
// 디바이스 목록에 두 디바이스가 모두 표시되는지 확인
expect(screen.getByText('sensor-001')).toBeInTheDocument();
expect(screen.getByText('sensor-002')).toBeInTheDocument();
expect(screen.getByText('센서 1')).toBeInTheDocument();
expect(screen.getByText('센서 2')).toBeInTheDocument();
// 활성 디바이스 수가 올바르게 표시되는지 확인
expect(screen.getByText('2')).toBeInTheDocument(); // active device count
});
});
describe('데이터 변환 오류 처리', () => {
it('데이터 변환 중 오류가 발생해도 기본값을 사용한다', () => {
// 변환 함수에서 오류를 발생시키는 잘못된 데이터
const invalidDataStructure = [
{
device_id: 'sensor-001',
// 필수 필드가 누락된 잘못된 구조
}
];
mockUseDevices.mockReturnValue({
devices: mockDevices,
loading: false,
error: null,
refetch: jest.fn()
});
mockUseSensorData.mockReturnValue({
latestData: invalidDataStructure,
loading: false,
error: null,
refetch: jest.fn()
});
renderWithRouter(<Dashboard />);
// 대시보드는 여전히 렌더링되어야 함
expect(screen.getByText('센서 대시보드')).toBeInTheDocument();
expect(screen.getByText('활성 디바이스')).toBeInTheDocument();
});
});
});
\ No newline at end of file
import { getDevices, getLatestData, getSensorHistory } from '../api';
import axios from 'axios';
// axios 모킹
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('API 서비스 오류 시나리오 테스트', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getDevices 오류 처리', () => {
it('네트워크 오류 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network Error'));
const result = await getDevices();
expect(result).toBeNull();
});
it('서버 오류(500) 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue({
response: {
status: 500,
data: { message: 'Internal Server Error' }
}
});
const result = await getDevices();
expect(result).toBeNull();
});
it('클라이언트 오류(404) 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue({
response: {
status: 404,
data: { message: 'Not Found' }
}
});
const result = await getDevices();
expect(result).toBeNull();
});
it('타임아웃 오류 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue(new Error('timeout of 5000ms exceeded'));
const result = await getDevices();
expect(result).toBeNull();
});
it('잘못된 응답 구조 시 null을 반환한다', async () => {
mockedAxios.get.mockResolvedValue({
data: { invalid_key: 'invalid_data' } // 올바른 구조가 아님
});
const result = await getDevices();
expect(result).toBeNull();
});
it('빈 배열 응답 시 빈 배열을 반환한다', async () => {
mockedAxios.get.mockResolvedValue({
data: []
});
const result = await getDevices();
expect(result).toEqual([]);
});
});
describe('getLatestData 오류 처리', () => {
it('네트워크 오류 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network Error'));
const result = await getLatestData(['sensor-001']);
expect(result).toBeNull();
});
it('서버 오류(500) 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue({
response: {
status: 500,
data: { message: 'Internal Server Error' }
}
});
const result = await getLatestData(['sensor-001']);
expect(result).toBeNull();
});
it('클라이언트 오류(404) 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue({
response: {
status: 404,
data: { message: 'Not Found' }
}
});
const result = await getLatestData(['sensor-001']);
expect(result).toBeNull();
});
it('타임아웃 오류 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue(new Error('timeout of 5000ms exceeded'));
const result = await getLatestData(['sensor-001']);
expect(result).toBeNull();
});
it('빈 디바이스 ID 배열 시 빈 배열을 반환한다', async () => {
const result = await getLatestData([]);
expect(result).toEqual([]);
});
it('잘못된 응답 구조 시 null을 반환한다', async () => {
mockedAxios.get.mockResolvedValue({
data: { invalid_key: 'invalid_data' }
});
const result = await getLatestData(['sensor-001']);
expect(result).toBeNull();
});
it('필수 필드가 누락된 데이터 시 null을 반환한다', async () => {
mockedAxios.get.mockResolvedValue({
data: [
{
// device_id가 누락됨
temperature: 25.5,
humidity: 60.0
}
]
});
const result = await getLatestData(['sensor-001']);
expect(result).toBeNull();
});
});
describe('getSensorHistory 오류 처리', () => {
it('네트워크 오류 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network Error'));
const result = await getSensorHistory('sensor-001', 'temperature', '1h');
expect(result).toBeNull();
});
it('서버 오류(500) 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue({
response: {
status: 500,
data: { message: 'Internal Server Error' }
}
});
const result = await getSensorHistory('sensor-001', 'temperature', '1h');
expect(result).toBeNull();
});
it('클라이언트 오류(404) 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue({
response: {
status: 404,
data: { message: 'Not Found' }
}
});
const result = await getSensorHistory('sensor-001', 'temperature', '1h');
expect(result).toBeNull();
});
it('타임아웃 오류 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue(new Error('timeout of 5000ms exceeded'));
const result = await getSensorHistory('sensor-001', 'temperature', '1h');
expect(result).toBeNull();
});
it('잘못된 디바이스 ID 시 null을 반환한다', async () => {
const result = await getSensorHistory('', 'temperature', '1h');
expect(result).toBeNull();
});
it('잘못된 센서 타입 시 null을 반환한다', async () => {
const result = await getSensorHistory('sensor-001', '', '1h');
expect(result).toBeNull();
});
it('잘못된 시간 범위 시 null을 반환한다', async () => {
const result = await getSensorHistory('sensor-001', 'temperature', '');
expect(result).toBeNull();
});
it('잘못된 응답 구조 시 null을 반환한다', async () => {
mockedAxios.get.mockResolvedValue({
data: { invalid_key: 'invalid_data' }
});
const result = await getSensorHistory('sensor-001', 'temperature', '1h');
expect(result).toBeNull();
});
});
describe('응답 데이터 검증', () => {
it('getDevices에서 필수 필드가 누락된 디바이스 데이터를 필터링한다', async () => {
mockedAxios.get.mockResolvedValue({
data: [
{
id: 1,
device_id: 'sensor-001',
name: '센서 1',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
},
{
id: 2,
// device_id가 누락됨
name: '센서 2',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
},
{
id: 3,
device_id: 'sensor-003',
name: '센서 3',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
}
]
});
const result = await getDevices();
expect(result).toHaveLength(2); // device_id가 있는 2개만 반환
expect(result?.[0].device_id).toBe('sensor-001');
expect(result?.[1].device_id).toBe('sensor-003');
});
it('getLatestData에서 필수 필드가 누락된 센서 데이터를 필터링한다', async () => {
mockedAxios.get.mockResolvedValue({
data: [
{
device_id: 'sensor-001',
temperature: 25.5,
humidity: 60.0
},
{
// device_id가 누락됨
temperature: 26.0,
humidity: 65.0
},
{
device_id: 'sensor-003',
temperature: 24.5,
humidity: 55.0
}
]
});
const result = await getLatestData(['sensor-001', 'sensor-002', 'sensor-003']);
expect(result).toHaveLength(2); // device_id가 있는 2개만 반환
expect(result?.[0].device_id).toBe('sensor-001');
expect(result?.[1].device_id).toBe('sensor-003');
});
it('getSensorHistory에서 필수 필드가 누락된 히스토리 데이터를 필터링한다', async () => {
mockedAxios.get.mockResolvedValue({
data: [
{
timestamp: '2025-08-01T10:00:00Z',
value: 25.5
},
{
// timestamp가 누락됨
value: 26.0
},
{
timestamp: '2025-08-01T10:01:00Z',
value: 24.5
}
]
});
const result = await getSensorHistory('sensor-001', 'temperature', '1h');
expect(result).toHaveLength(2); // timestamp가 있는 2개만 반환
expect(result?.[0].timestamp).toBe('2025-08-01T10:00:00Z');
expect(result?.[1].timestamp).toBe('2025-08-01T10:01:00Z');
});
});
describe('타임아웃 및 재시도 로직', () => {
it('첫 번째 요청이 실패하고 두 번째 요청이 성공하는 경우', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValueOnce({
data: [
{
id: 1,
device_id: 'sensor-001',
name: '센서 1',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
}
]
});
const result = await getDevices();
expect(result).toHaveLength(1);
expect(result?.[0].device_id).toBe('sensor-001');
});
it('연속적인 네트워크 오류 시 null을 반환한다', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network Error'));
const result = await getDevices();
expect(result).toBeNull();
});
});
describe('데이터 타입 검증', () => {
it('getDevices에서 잘못된 타입의 데이터를 필터링한다', async () => {
mockedAxios.get.mockResolvedValue({
data: [
{
id: 1,
device_id: 'sensor-001',
name: '센서 1',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
},
{
id: 'invalid_id', // 숫자가 아닌 ID
device_id: 'sensor-002',
name: '센서 2',
status: 'active',
last_seen: '2025-08-01T10:00:00Z'
}
]
});
const result = await getDevices();
expect(result).toHaveLength(1); // 유효한 타입의 데이터만 반환
expect(result?.[0].id).toBe(1);
});
it('getLatestData에서 잘못된 타입의 센서 값을 처리한다', async () => {
mockedAxios.get.mockResolvedValue({
data: [
{
device_id: 'sensor-001',
temperature: '25.5', // 문자열
humidity: 60.0,
pm10: null,
pm25: undefined
}
]
});
const result = await getLatestData(['sensor-001']);
expect(result).toHaveLength(1);
expect(result?.[0].device_id).toBe('sensor-001');
// 타입 변환은 프론트엔드에서 처리되므로 여기서는 원본 데이터 반환
});
});
});
\ No newline at end of file
import axios, { AxiosError, AxiosResponse } from 'axios';
import { isSensorData, isDevice, isHistoryResponse, isApiResponse } from '../utils/typeGuards';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://sensor.geumdo.net';
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 30000, // 30초로 증가
});
// API 설정 로깅
console.log('🔧 API Configuration:', {
baseURL: API_BASE_URL,
timeout: 30000,
env: process.env.REACT_APP_API_URL
});
// API 응답 구조 검증 함수 - 강화된 검증
const validateApiResponse = (response: any, endpoint: string): boolean => {
if (!response || typeof response !== 'object') {
console.error(`Invalid response type for ${endpoint}:`, typeof response);
return false;
}
// 엔드포인트별 검증 로직
if (endpoint.includes('/latest')) {
return response.hasOwnProperty('data') && response.data !== null;
}
if (endpoint.includes('/devices') && !endpoint.includes('/history')) {
return response.hasOwnProperty('devices') && Array.isArray(response.devices);
}
if (endpoint.includes('/history')) {
const isValid = response.hasOwnProperty('success') &&
response.hasOwnProperty('data') &&
response.hasOwnProperty('total') &&
Array.isArray(response.data);
if (!isValid) {
console.error(`Invalid history response structure for ${endpoint}:`, {
hasSuccess: response.hasOwnProperty('success'),
hasData: response.hasOwnProperty('data'),
hasTotal: response.hasOwnProperty('total'),
dataIsArray: Array.isArray(response.data),
response: response
});
}
return isValid;
}
return true;
};
// 센서 데이터 필수 필드 검증
const validateSensorDataFields = (data: any): boolean => {
const requiredFields = [
'id', 'device_id', 'node_id', 'temperature', 'humidity',
'longitude', 'latitude', 'recorded_time', 'received_time'
];
return requiredFields.every(field => {
const value = data[field];
if (field === 'device_id' || field === 'recorded_time' || field === 'received_time') {
return typeof value === 'string' && value.length > 0;
}
if (field === 'id' || field === 'node_id') {
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
}
if (field === 'temperature' || field === 'humidity' || field === 'longitude' || field === 'latitude') {
return typeof value === 'number' && !isNaN(value) && isFinite(value);
}
return true;
});
};
// 디바이스 데이터 필수 필드 검증
const validateDeviceFields = (data: any): boolean => {
const requiredFields = [
'id', 'device_id', 'name', 'description', 'status',
'last_seen', 'created_at', 'updated_at'
];
return requiredFields.every(field => {
const value = data[field];
if (field === 'id') {
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
}
if (['device_id', 'name', 'status'].includes(field)) {
return typeof value === 'string' && value.length > 0;
}
if (field === 'description') {
return typeof value === 'string'; // description은 빈 문자열도 허용
}
if (['last_seen', 'created_at', 'updated_at'].includes(field)) {
return typeof value === 'string' && value.length > 0 && !isNaN(Date.parse(value));
}
return true;
});
};
// 데이터 타입 일관성 검사
const validateDataConsistency = (data: any, type: 'sensor' | 'device' | 'history'): boolean => {
try {
switch (type) {
case 'sensor':
return isSensorData(data) && validateSensorDataFields(data);
case 'device':
return isDevice(data) && validateDeviceFields(data);
case 'history':
return isHistoryResponse(data);
default:
return false;
}
} catch (error) {
console.error(`Data consistency validation failed for type ${type}:`, error);
return false;
}
};
// 전역 에러 처리 인터셉터
api.interceptors.response.use(
(response: AxiosResponse) => {
console.log('API Response:', response.config.url, response.data);
// 응답 구조 검증
if (!validateApiResponse(response.data, response.config.url || '')) {
throw new Error('Invalid API response structure');
}
return response;
},
(error: AxiosError) => {
console.error('Axios Error Details:', {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers
});
// 네트워크 에러 처리
if (!error.response) {
console.error('Network error:', error.message);
throw new Error('네트워크 연결에 실패했습니다. 인터넷 연결을 확인해주세요.');
}
// HTTP 상태 코드별 에러 처리
const status = error.response.status;
let errorMessage = '알 수 없는 오류가 발생했습니다.';
switch (status) {
case 400:
errorMessage = '잘못된 요청입니다.';
break;
case 401:
errorMessage = '인증이 필요합니다.';
break;
case 403:
errorMessage = '접근 권한이 없습니다.';
break;
case 404:
errorMessage = '요청한 리소스를 찾을 수 없습니다.';
break;
case 500:
errorMessage = '서버 내부 오류가 발생했습니다.';
break;
case 503:
errorMessage = '서비스가 일시적으로 사용할 수 없습니다.';
break;
default:
errorMessage = `서버 오류 (${status})가 발생했습니다.`;
}
console.error(`API Error ${status}:`, error.response.data);
throw new Error(errorMessage);
}
);
export interface SensorData {
id: number;
device_id: string;
node_id: number;
temperature: number;
humidity: number;
longitude: number;
latitude: number;
// 추가 센서 데이터 필드
float_value?: number;
signed_int32_value?: number;
unsigned_int32_value?: number;
raw_tem?: number;
raw_hum?: number;
// 환경 센서 데이터
pm10?: number;
pm25?: number;
pressure?: number;
illumination?: number;
tvoc?: number;
co2?: number;
o2?: number;
co?: number;
recorded_time: string;
received_time: string;
}
export interface Device {
id: number;
device_id: string;
name: string;
description: string;
status: string;
last_seen: string;
created_at: string;
updated_at: string;
}
export interface HistoryResponse {
success: boolean;
data: SensorData[];
total: number;
}
// 센서 데이터 수신
export const sendSensorData = async (data: any): Promise<any> => {
console.log('📤 센서 데이터 전송 시작:', data);
try {
const response = await api.post('/api/sensor-data', data);
console.log('✅ 센서 데이터 전송 성공:', response.data);
return response.data;
} catch (error) {
console.error('❌ 센서 데이터 전송 실패:', error);
throw error;
}
};
// 최신 센서 데이터 조회 - 타입 안전성 및 데이터 검증 강화
export const getLatestData = async (deviceId: string): Promise<SensorData | null> => {
const startTime = Date.now();
const url = `/api/devices/${deviceId}/latest`;
console.log(`📡 최신 데이터 요청 시작 - 디바이스: ${deviceId}`);
console.log(`🔗 요청 URL: ${process.env.REACT_APP_API_URL || 'http://sensor.geumdo.net'}${url}`);
try {
const response = await api.get(url);
const responseTime = Date.now() - startTime;
console.log(`✅ 최신 데이터 요청 성공 - 디바이스: ${deviceId} (응답시간: ${responseTime}ms)`);
console.log(`📊 응답 데이터 크기: ${JSON.stringify(response.data).length} bytes`);
// 응답 데이터 검증
if (!response.data || !response.data.data) {
console.warn(`⚠️ 디바이스 ${deviceId}에 대한 데이터가 없습니다.`);
console.log('🔍 응답 구조:', response.data);
return null;
}
const sensorData = response.data.data;
// 필수 필드 검증
if (!sensorData.device_id || typeof sensorData.device_id !== 'string') {
console.error(`❌ 디바이스 ${deviceId} 응답에서 잘못된 device_id:`, sensorData);
return null;
}
// 데이터 타입 일관성 검사
if (!validateDataConsistency(sensorData, 'sensor')) {
console.error(`❌ 디바이스 ${deviceId} 데이터 일관성 검증 실패:`, sensorData);
return null;
}
console.log(`✅ 디바이스 ${deviceId} 데이터 검증 완료:`, {
device_id: sensorData.device_id,
temperature: sensorData.temperature,
humidity: sensorData.humidity,
recorded_time: sensorData.recorded_time
});
return sensorData;
} catch (error) {
const responseTime = Date.now() - startTime;
console.error(`❌ 디바이스 ${deviceId} 최신 데이터 요청 실패 (응답시간: ${responseTime}ms):`, error);
if (error instanceof Error) {
console.error('🔍 에러 상세 정보:', {
message: error.message,
name: error.name,
stack: error.stack
});
}
return null;
}
};
// 히스토리 데이터 조회 - 데이터 검증 강화
export const getHistory = async (
deviceId: string,
limit: number = 100,
offset: number = 0,
startTime?: string,
endTime?: string
): Promise<HistoryResponse> => {
const startTimeRequest = Date.now();
const params: any = { limit, offset };
if (startTime) params.start_time = startTime;
if (endTime) params.end_time = endTime;
console.log(`📡 히스토리 데이터 요청 시작 - 디바이스: ${deviceId}`);
console.log(`🔧 요청 파라미터:`, params);
try {
const response = await api.get(`/api/devices/${deviceId}/history`, { params });
const responseTime = Date.now() - startTimeRequest;
console.log(`✅ 히스토리 데이터 요청 성공 - 디바이스: ${deviceId} (응답시간: ${responseTime}ms)`);
console.log(`📊 수신된 데이터 수: ${response.data.data?.length || 0}개`);
// 응답 데이터 검증
if (!validateDataConsistency(response.data, 'history')) {
console.error(`❌ 디바이스 ${deviceId} 히스토리 데이터 검증 실패:`, response.data);
throw new Error('Invalid history data format');
}
console.log(`✅ 디바이스 ${deviceId} 히스토리 데이터 검증 완료`);
return response.data;
} catch (error) {
const responseTime = Date.now() - startTimeRequest;
console.error(`❌ 디바이스 ${deviceId} 히스토리 데이터 요청 실패 (응답시간: ${responseTime}ms):`, error);
throw error;
}
};
// 디바이스 목록 조회 - 데이터 검증 강화
export const getDevices = async (): Promise<Device[]> => {
const startTime = Date.now();
try {
const fullUrl = `${api.defaults.baseURL}/api/devices`;
console.log('🔍 디바이스 목록 조회 시작');
console.log('🔗 요청 URL:', fullUrl);
console.log('🔧 API 설정:', {
baseURL: api.defaults.baseURL,
timeout: api.defaults.timeout,
headers: api.defaults.headers
});
const response = await api.get('/api/devices');
const responseTime = Date.now() - startTime;
console.log('✅ 디바이스 목록 조회 성공 (응답시간:', responseTime, 'ms)');
console.log('📊 응답 데이터:', response.data);
// 응답 데이터 검증
if (!response.data.devices || !Array.isArray(response.data.devices)) {
console.error('❌ 잘못된 디바이스 응답 구조:', response.data);
throw new Error('Invalid devices data format');
}
console.log(`📊 총 디바이스 수: ${response.data.devices.length}개`);
// 각 디바이스 데이터 검증
const validDevices = response.data.devices.filter((device: any) => {
if (!validateDataConsistency(device, 'device')) {
console.warn('⚠️ 잘못된 디바이스 데이터 발견:', device);
return false;
}
return true;
});
if (validDevices.length !== response.data.devices.length) {
console.warn(`⚠️ ${response.data.devices.length - validDevices.length}개의 잘못된 디바이스 데이터가 필터링되었습니다.`);
}
console.log(`✅ 유효한 디바이스 수: ${validDevices.length}개`);
return validDevices;
} catch (error) {
const responseTime = Date.now() - startTime;
console.error('❌ 디바이스 목록 조회 실패 (응답시간:', responseTime, 'ms):', error);
throw error;
}
};
// 헬스체크
export const healthCheck = async (): Promise<any> => {
const startTime = Date.now();
console.log('🏥 헬스체크 시작');
try {
const response = await api.get('/api/health');
const responseTime = Date.now() - startTime;
console.log('✅ 헬스체크 성공 (응답시간:', responseTime, 'ms):', response.data);
return response.data;
} catch (error) {
const responseTime = Date.now() - startTime;
console.error('❌ 헬스체크 실패 (응답시간:', responseTime, 'ms):', error);
throw error;
}
};
export default api;
\ No newline at end of file
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
// Mock window.matchMedia (only if window exists)
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
// Mock WebSocket
global.WebSocket = class WebSocket {
readyState: number;
url: string;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(url: string) {
this.url = url;
this.readyState = 0; // CONNECTING
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {}
close(code?: number, reason?: string) {}
addEventListener(type: string, listener: EventListener) {}
removeEventListener(type: string, listener: EventListener) {}
};
// Mock console methods to reduce noise in tests
const originalError = console.error;
const originalWarn = console.warn;
beforeAll(() => {
console.error = (...args: any[]) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render is deprecated')
) {
return;
}
originalError.call(console, ...args);
};
console.warn = (...args: any[]) => {
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning: componentWillReceiveProps') ||
args[0].includes('Warning: componentWillUpdate'))
) {
return;
}
originalWarn.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
console.warn = originalWarn;
});
\ No newline at end of file
import { formatSensorValue, validateSensorValue, getSensorPrecision } from '../formatters';
describe('formatters', () => {
describe('formatSensorValue', () => {
it('should format valid numbers with default precision', () => {
expect(formatSensorValue(25.123456)).toBe('25.1');
expect(formatSensorValue(0)).toBe('0.0');
expect(formatSensorValue(-10.5)).toBe('-10.5');
});
it('should format numbers with custom precision', () => {
expect(formatSensorValue(25.123456, 2)).toBe('25.12');
expect(formatSensorValue(25.123456, 0)).toBe('25');
expect(formatSensorValue(25.123456, 4)).toBe('25.1235');
});
it('should handle very small values (e-40)', () => {
expect(formatSensorValue(1e-40)).toBe('0.0');
expect(formatSensorValue(1e-15)).toBe('0.0');
expect(formatSensorValue(1e-10)).toBe('0.0');
});
it('should handle undefined and null values', () => {
expect(formatSensorValue(undefined)).toBe('N/A');
expect(formatSensorValue(null as any)).toBe('N/A');
});
it('should handle infinite values', () => {
expect(formatSensorValue(Infinity)).toBe('N/A');
expect(formatSensorValue(-Infinity)).toBe('N/A');
});
it('should handle NaN values', () => {
expect(formatSensorValue(NaN)).toBe('N/A');
});
});
describe('validateSensorValue', () => {
it('should validate values within range', () => {
expect(validateSensorValue(25, 0, 50)).toBe(true);
expect(validateSensorValue(0, 0, 50)).toBe(true);
expect(validateSensorValue(50, 0, 50)).toBe(true);
});
it('should reject values outside range', () => {
expect(validateSensorValue(-1, 0, 50)).toBe(false);
expect(validateSensorValue(51, 0, 50)).toBe(false);
});
it('should handle very small values', () => {
expect(validateSensorValue(1e-40)).toBe(true); // 0 값은 유효
expect(validateSensorValue(1e-15)).toBe(true);
});
it('should reject invalid values', () => {
expect(validateSensorValue(undefined)).toBe(false);
expect(validateSensorValue(null as any)).toBe(false);
expect(validateSensorValue(Infinity)).toBe(false);
expect(validateSensorValue(-Infinity)).toBe(false);
expect(validateSensorValue(NaN)).toBe(false);
});
it('should use default range when not specified', () => {
expect(validateSensorValue(25)).toBe(true);
expect(validateSensorValue(-1001)).toBe(false);
expect(validateSensorValue(10001)).toBe(false);
});
});
describe('getSensorPrecision', () => {
it('should return correct precision for each sensor type', () => {
expect(getSensorPrecision('temperature')).toBe(1);
expect(getSensorPrecision('humidity')).toBe(1);
expect(getSensorPrecision('pressure')).toBe(1);
expect(getSensorPrecision('illumination')).toBe(0);
expect(getSensorPrecision('pm10')).toBe(1);
expect(getSensorPrecision('pm25')).toBe(1);
expect(getSensorPrecision('tvoc')).toBe(1);
expect(getSensorPrecision('co2')).toBe(0);
expect(getSensorPrecision('o2')).toBe(1);
expect(getSensorPrecision('co')).toBe(2);
});
it('should return default precision for unknown sensor types', () => {
expect(getSensorPrecision('unknown')).toBe(1);
expect(getSensorPrecision('')).toBe(1);
});
});
});
\ 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