feat: 완전한 사용자 관리 및 로그 모니터링 시스템 구현
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 시스템 관리자/관리자 권한별 대시보드 기능 추가 - 사용자 관리 페이지: 계정 생성, 역할 변경, 사용자 삭제 - 시스템 로그 페이지: 로그인 로그, 시스템 오류 로그 조회 - 로그 모니터링 대시보드: 실시간 통계, 최근 활동, 오류 모니터링 - 프론트엔드 ErrorBoundary 및 오류 로깅 시스템 통합 - 계정 설정 페이지: 프로필 업데이트, 비밀번호 변경 - 3단계 권한 시스템 (system/admin/user) 완전 구현 - 시스템 관리자 계정 생성 기능 (hyungi/000000) - 로그인 페이지 테스트 계정 안내 제거 - API 오류 수정: CORS, 이메일 검증, User 모델 import 등
This commit is contained in:
459
frontend/src/pages/LogMonitoringPage.jsx
Normal file
459
frontend/src/pages/LogMonitoringPage.jsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
import { reportError, logUserActionError } from '../utils/errorLogger';
|
||||
|
||||
const LogMonitoringPage = ({ onNavigate, user }) => {
|
||||
const [stats, setStats] = useState({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
todayLogins: 0,
|
||||
failedLogins: 0,
|
||||
recentErrors: 0
|
||||
});
|
||||
const [recentActivity, setRecentActivity] = 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 loadDashboardData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 병렬로 데이터 로드
|
||||
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>
|
||||
<p style={{ color: '#6c757d', fontSize: '14px', margin: '4px 0 0 0' }}>
|
||||
실시간 시스템 활동 및 오류 모니터링
|
||||
</p>
|
||||
</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;
|
||||
Reference in New Issue
Block a user