Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
Administrator
sensor_mvp
Commits
fcfb9aec
Commit
fcfb9aec
authored
Sep 02, 2025
by
Sensor MVP Team
Browse files
initial draft
parent
704ef42a
Changes
87
Show whitespace changes
Inline
Side-by-side
web-dashboard/src/components/Tooltip.tsx
0 → 100644
View file @
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
web-dashboard/src/components/__tests__/SensorCard.test.tsx
0 → 100644
View file @
fcfb9aec
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
=
{
1
e
-
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
web-dashboard/src/components/__tests__/SensorChart.test.tsx
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/contexts/ThemeContext.tsx
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/hooks/__tests__/useSensorData.test.ts
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/hooks/useDevices.ts
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/hooks/useMonitoring.ts
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/hooks/useRealTimeData.ts
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/hooks/useSensorData.ts
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/index.css
0 → 100644
View file @
fcfb9aec
@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
web-dashboard/src/index.tsx
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/pages/Dashboard.tsx
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/pages/Devices.tsx
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/pages/History.tsx
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/pages/Settings.tsx
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/pages/__tests__/Dashboard.data.test.tsx
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/services/__tests__/api.error.test.ts
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/services/api.ts
0 → 100644
View file @
fcfb9aec
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
web-dashboard/src/setupTests.ts
0 → 100644
View file @
fcfb9aec
// 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
web-dashboard/src/utils/__tests__/formatters.test.ts
0 → 100644
View file @
fcfb9aec
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
(
1
e
-
40
)).
toBe
(
'
0.0
'
);
expect
(
formatSensorValue
(
1
e
-
15
)).
toBe
(
'
0.0
'
);
expect
(
formatSensorValue
(
1
e
-
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
(
1
e
-
40
)).
toBe
(
true
);
// 0 값은 유효
expect
(
validateSensorValue
(
1
e
-
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
Prev
1
2
3
4
5
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment