Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 사용자 요구사항 저장/로드/엑셀 내보내기 기능 완전 구현 - 백엔드 API 수정: Request Body 방식으로 변경 - 데이터베이스 스키마: material_id 컬럼 추가 - 프론트엔드 상태 관리 개선: 저장 후 자동 리로드 - 입력 필드 연결 문제 해결: 누락된 onChange 핸들러 추가 - NewMaterialsPage에 '전체' 카테고리 버튼 추가 (기본 선택) - Docker 환경 개선: 프론트엔드 볼륨 마운트 및 포트 수정 - UI 개선: 벌레 이모지 제거, 디버그 코드 정리
529 lines
24 KiB
JavaScript
529 lines
24 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import api from '../api';
|
||
import { reportError, logUserActionError } from '../utils/errorLogger';
|
||
|
||
const LogMonitoringPage = ({ onNavigate, user }) => {
|
||
const [activeTab, setActiveTab] = useState('login-logs'); // 'login-logs', 'activity-logs', 'system-logs'
|
||
const [stats, setStats] = useState({
|
||
totalUsers: 0,
|
||
activeUsers: 0,
|
||
todayLogins: 0,
|
||
failedLogins: 0,
|
||
recentErrors: 0
|
||
});
|
||
const [recentActivity, setRecentActivity] = useState([]);
|
||
const [activityLogs, setActivityLogs] = useState([]);
|
||
const [frontendErrors, setFrontendErrors] = useState([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [message, setMessage] = useState({ type: '', text: '' });
|
||
|
||
useEffect(() => {
|
||
loadDashboardData();
|
||
// 30초마다 자동 새로고침
|
||
const interval = setInterval(loadDashboardData, 30000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
const loadActivityLogs = async () => {
|
||
try {
|
||
const response = await api.get('/auth/logs/system?limit=50');
|
||
if (response.data.success) {
|
||
setActivityLogs(response.data.logs);
|
||
}
|
||
} catch (error) {
|
||
console.error('활동 로그 로딩 실패:', error);
|
||
}
|
||
};
|
||
|
||
const loadDashboardData = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
|
||
// 활동 로그도 함께 로드
|
||
if (activeTab === 'activity-logs') {
|
||
await loadActivityLogs();
|
||
}
|
||
|
||
// 병렬로 데이터 로드
|
||
const [usersResponse, loginLogsResponse] = await Promise.all([
|
||
api.get('/auth/users'),
|
||
api.get('/auth/logs/login', { params: { limit: 20 } })
|
||
]);
|
||
|
||
// 사용자 통계
|
||
if (usersResponse.data.success) {
|
||
const users = usersResponse.data.users;
|
||
setStats(prev => ({
|
||
...prev,
|
||
totalUsers: users.length,
|
||
activeUsers: users.filter(u => u.is_active).length
|
||
}));
|
||
}
|
||
|
||
// 로그인 로그 통계
|
||
if (loginLogsResponse.data.success) {
|
||
const logs = loginLogsResponse.data.logs;
|
||
const today = new Date().toDateString();
|
||
|
||
const todayLogins = logs.filter(log =>
|
||
new Date(log.login_time).toDateString() === today &&
|
||
log.login_status === 'success'
|
||
).length;
|
||
|
||
const failedLogins = logs.filter(log =>
|
||
new Date(log.login_time).toDateString() === today &&
|
||
log.login_status === 'failed'
|
||
).length;
|
||
|
||
setStats(prev => ({
|
||
...prev,
|
||
todayLogins,
|
||
failedLogins
|
||
}));
|
||
|
||
setRecentActivity(logs.slice(0, 10));
|
||
}
|
||
|
||
// 프론트엔드 오류 로그 (로컬 스토리지에서)
|
||
const localErrors = JSON.parse(localStorage.getItem('frontend_errors') || '[]');
|
||
const recentErrors = localErrors.filter(error => {
|
||
const errorDate = new Date(error.timestamp);
|
||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||
return errorDate > oneDayAgo;
|
||
});
|
||
|
||
setFrontendErrors(recentErrors.slice(0, 10));
|
||
setStats(prev => ({
|
||
...prev,
|
||
recentErrors: recentErrors.length
|
||
}));
|
||
|
||
} catch (err) {
|
||
console.error('Load dashboard data error:', err);
|
||
setMessage({ type: 'error', text: '대시보드 데이터 로드 중 오류가 발생했습니다' });
|
||
logUserActionError('load_dashboard_data', err, { userId: user?.user_id });
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const clearFrontendErrors = () => {
|
||
localStorage.removeItem('frontend_errors');
|
||
setFrontendErrors([]);
|
||
setStats(prev => ({ ...prev, recentErrors: 0 }));
|
||
setMessage({ type: 'success', text: '프론트엔드 오류 로그가 삭제되었습니다' });
|
||
};
|
||
|
||
const formatDateTime = (dateString) => {
|
||
try {
|
||
return new Date(dateString).toLocaleString('ko-KR', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
} catch {
|
||
return dateString;
|
||
}
|
||
};
|
||
|
||
const getActivityIcon = (status) => {
|
||
return status === 'success' ? '✅' : '❌';
|
||
};
|
||
|
||
const getErrorTypeIcon = (type) => {
|
||
const icons = {
|
||
'javascript_error': '❌',
|
||
'api_error': '🌐',
|
||
'user_action_error': '👤',
|
||
'promise_rejection': '⚠️',
|
||
'react_error_boundary': '⚛️'
|
||
};
|
||
return icons[type] || '❓';
|
||
};
|
||
|
||
return (
|
||
<div style={{ minHeight: '100vh', background: '#f8f9fa' }}>
|
||
{/* 헤더 */}
|
||
<div style={{
|
||
background: 'white',
|
||
borderBottom: '1px solid #e9ecef',
|
||
padding: '16px 32px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||
<button
|
||
onClick={() => onNavigate('dashboard')}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: '#28a745',
|
||
fontSize: '20px',
|
||
cursor: 'pointer',
|
||
padding: '4px',
|
||
borderRadius: '4px',
|
||
transition: 'background-color 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => e.target.style.background = '#f8f9fa'}
|
||
onMouseLeave={(e) => e.target.style.background = 'none'}
|
||
title="대시보드로 돌아가기"
|
||
>
|
||
←
|
||
</button>
|
||
<div>
|
||
<h1 style={{ fontSize: '24px', fontWeight: '700', color: '#2d3748', margin: 0 }}>
|
||
📈 로그 모니터링
|
||
</h1>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 탭 네비게이션 */}
|
||
<div style={{ background: 'white', borderBottom: '1px solid #e9ecef', padding: '0 32px' }}>
|
||
<div style={{ display: 'flex', gap: '0' }}>
|
||
<button
|
||
onClick={() => setActiveTab('login-logs')}
|
||
style={{
|
||
padding: '12px 24px',
|
||
border: 'none',
|
||
background: activeTab === 'login-logs' ? '#4299e1' : 'transparent',
|
||
color: activeTab === 'login-logs' ? 'white' : '#4a5568',
|
||
borderRadius: '8px 8px 0 0',
|
||
cursor: 'pointer',
|
||
fontWeight: '600',
|
||
fontSize: '14px',
|
||
borderBottom: activeTab === 'login-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||
}}
|
||
>
|
||
🔐 로그인 로그
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('activity-logs')}
|
||
style={{
|
||
padding: '12px 24px',
|
||
border: 'none',
|
||
background: activeTab === 'activity-logs' ? '#4299e1' : 'transparent',
|
||
color: activeTab === 'activity-logs' ? 'white' : '#4a5568',
|
||
borderRadius: '8px 8px 0 0',
|
||
cursor: 'pointer',
|
||
fontWeight: '600',
|
||
fontSize: '14px',
|
||
borderBottom: activeTab === 'activity-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||
}}
|
||
>
|
||
📊 활동 로그
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('system-logs')}
|
||
style={{
|
||
padding: '12px 24px',
|
||
border: 'none',
|
||
background: activeTab === 'system-logs' ? '#4299e1' : 'transparent',
|
||
color: activeTab === 'system-logs' ? 'white' : '#4a5568',
|
||
borderRadius: '8px 8px 0 0',
|
||
cursor: 'pointer',
|
||
fontWeight: '600',
|
||
fontSize: '14px',
|
||
borderBottom: activeTab === 'system-logs' ? '2px solid #4299e1' : '2px solid transparent'
|
||
}}
|
||
>
|
||
🖥️ 시스템 로그
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '12px' }}>
|
||
<button
|
||
onClick={loadDashboardData}
|
||
disabled={isLoading}
|
||
style={{
|
||
background: '#007bff',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
padding: '8px 16px',
|
||
fontSize: '14px',
|
||
fontWeight: '600',
|
||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||
opacity: isLoading ? 0.6 : 1
|
||
}}
|
||
>
|
||
🔄 새로고침
|
||
</button>
|
||
|
||
{frontendErrors.length > 0 && (
|
||
<button
|
||
onClick={clearFrontendErrors}
|
||
style={{
|
||
background: '#dc3545',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
padding: '8px 16px',
|
||
fontSize: '14px',
|
||
fontWeight: '600',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
🗑️ 오류 로그 삭제
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: '32px', maxWidth: '1400px', margin: '0 auto' }}>
|
||
{/* 메시지 표시 */}
|
||
{message.text && (
|
||
<div style={{
|
||
padding: '12px 16px',
|
||
borderRadius: '8px',
|
||
marginBottom: '24px',
|
||
backgroundColor: message.type === 'success' ? '#d1edff' : '#f8d7da',
|
||
border: `1px solid ${message.type === 'success' ? '#bee5eb' : '#f5c6cb'}`,
|
||
color: message.type === 'success' ? '#0c5460' : '#721c24'
|
||
}}>
|
||
{message.text}
|
||
</div>
|
||
)}
|
||
|
||
{/* 통계 카드 */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||
gap: '20px',
|
||
marginBottom: '32px'
|
||
}}>
|
||
<div style={{
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
padding: '24px',
|
||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||
border: '1px solid #e9ecef'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||
<div style={{ fontSize: '24px' }}>👥</div>
|
||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||
전체 사용자
|
||
</h3>
|
||
</div>
|
||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#2d3748' }}>
|
||
{stats.totalUsers}
|
||
</div>
|
||
<div style={{ fontSize: '14px', color: '#28a745' }}>
|
||
활성: {stats.activeUsers}명
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
padding: '24px',
|
||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||
border: '1px solid #e9ecef'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||
<div style={{ fontSize: '24px' }}>✅</div>
|
||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||
오늘 로그인
|
||
</h3>
|
||
</div>
|
||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#28a745' }}>
|
||
{stats.todayLogins}
|
||
</div>
|
||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||
성공한 로그인
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
padding: '24px',
|
||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||
border: '1px solid #e9ecef'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||
<div style={{ fontSize: '24px' }}>❌</div>
|
||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||
로그인 실패
|
||
</h3>
|
||
</div>
|
||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#dc3545' }}>
|
||
{stats.failedLogins}
|
||
</div>
|
||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||
오늘 실패 횟수
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
padding: '24px',
|
||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||
border: '1px solid #e9ecef'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||
<div style={{ fontSize: '24px' }}>❌</div>
|
||
<h3 style={{ fontSize: '16px', fontWeight: '600', color: '#495057', margin: 0 }}>
|
||
최근 오류
|
||
</h3>
|
||
</div>
|
||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#ffc107' }}>
|
||
{stats.recentErrors}
|
||
</div>
|
||
<div style={{ fontSize: '14px', color: '#6c757d' }}>
|
||
24시간 내
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 콘텐츠 그리드 */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: '24px'
|
||
}}>
|
||
{/* 최근 활동 */}
|
||
<div style={{
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||
overflow: 'hidden'
|
||
}}>
|
||
<div style={{
|
||
padding: '20px 24px',
|
||
borderBottom: '1px solid #e9ecef',
|
||
background: '#f8f9fa'
|
||
}}>
|
||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||
🔐 최근 로그인 활동
|
||
</h2>
|
||
</div>
|
||
|
||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||
{isLoading ? (
|
||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||
<div style={{ fontSize: '16px', color: '#6c757d' }}>로딩 중...</div>
|
||
</div>
|
||
) : recentActivity.length === 0 ? (
|
||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||
<div style={{ fontSize: '16px', color: '#6c757d' }}>최근 활동이 없습니다</div>
|
||
</div>
|
||
) : (
|
||
recentActivity.map((activity, index) => (
|
||
<div key={index} style={{
|
||
padding: '16px 24px',
|
||
borderBottom: '1px solid #f1f3f4',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px'
|
||
}}>
|
||
<div style={{ fontSize: '20px' }}>
|
||
{getActivityIcon(activity.login_status)}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#2d3748' }}>
|
||
{activity.name}
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||
@{activity.username} • {activity.ip_address}
|
||
</div>
|
||
{activity.failure_reason && (
|
||
<div style={{ fontSize: '12px', color: '#dc3545', marginTop: '2px' }}>
|
||
{activity.failure_reason}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: '#6c757d' }}>
|
||
{formatDateTime(activity.login_time)}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 프론트엔드 오류 */}
|
||
<div style={{
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||
overflow: 'hidden'
|
||
}}>
|
||
<div style={{
|
||
padding: '20px 24px',
|
||
borderBottom: '1px solid #e9ecef',
|
||
background: '#f8f9fa'
|
||
}}>
|
||
<h2 style={{ fontSize: '18px', fontWeight: '600', color: '#2d3748', margin: 0 }}>
|
||
❌ 프론트엔드 오류
|
||
</h2>
|
||
</div>
|
||
|
||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||
{frontendErrors.length === 0 ? (
|
||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||
<div style={{ fontSize: '16px', color: '#6c757d' }}>최근 오류가 없습니다</div>
|
||
</div>
|
||
) : (
|
||
frontendErrors.map((error, index) => (
|
||
<div key={index} style={{
|
||
padding: '16px 24px',
|
||
borderBottom: '1px solid #f1f3f4',
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: '12px'
|
||
}}>
|
||
<div style={{ fontSize: '16px', marginTop: '2px' }}>
|
||
{getErrorTypeIcon(error.type)}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#dc3545' }}>
|
||
{error.type?.replace('_', ' ').toUpperCase() || 'ERROR'}
|
||
</div>
|
||
<div style={{
|
||
fontSize: '13px',
|
||
color: '#495057',
|
||
marginTop: '4px',
|
||
wordBreak: 'break-word',
|
||
lineHeight: '1.4'
|
||
}}>
|
||
{error.message?.substring(0, 100)}
|
||
{error.message?.length > 100 && '...'}
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: '#6c757d', marginTop: '4px' }}>
|
||
{error.url && (
|
||
<span>{new URL(error.url).pathname} • </span>
|
||
)}
|
||
{formatDateTime(error.timestamp)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 자동 새로고침 안내 */}
|
||
<div style={{
|
||
marginTop: '24px',
|
||
padding: '16px',
|
||
backgroundColor: '#e3f2fd',
|
||
border: '1px solid #bbdefb',
|
||
borderRadius: '8px',
|
||
textAlign: 'center'
|
||
}}>
|
||
<p style={{ fontSize: '14px', color: '#1565c0', margin: 0 }}>
|
||
📊 이 페이지는 30초마다 자동으로 새로고침됩니다
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default LogMonitoringPage;
|