diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index da5d717..5927ecc 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). ## [Unreleased] +## [2026-04-13] + +### 추가 + +- 로그인 프로세스 제거 + ADMIN/MANAGER/USER 역할 토글 버튼 (#35) +- RoleGuard 컴포넌트로 관리자 전용 페이지 접근 제어 (#35) +- Request Logs 날짜 프리셋 버튼 (오늘/어제/최근7일/이번달/지난달) (#35) +- health_log 일별 파티셔닝 + 복합 인덱스 최적화 (#35) + +### 변경 + +- SecurityConfig permitAll 전환, @PreAuthorize 전체 제거 (#35) +- X-User-Id 헤더 기반 사용자 식별로 전환 (#35) +- Request Logs 필터 영역 한 줄 통합, IP 필드 제거 (#35) +- PartitionService 범용화 (테이블명 파라미터) (#35) +- DataCleanupScheduler health_log DELETE → 파티션 DROP 전환 (#35) + ## [2026-04-10] ### 추가 diff --git a/docs/schema/create_tables.sql b/docs/schema/create_tables.sql index ae5d74a..55344dc 100644 --- a/docs/schema/create_tables.sql +++ b/docs/schema/create_tables.sql @@ -81,6 +81,8 @@ CREATE TABLE IF NOT EXISTS snp_service_health_log ( CREATE INDEX idx_snp_health_log_service ON snp_service_health_log (service_id); CREATE INDEX idx_snp_health_log_checked ON snp_service_health_log (checked_at); +CREATE INDEX idx_health_log_svc_checked ON snp_service_health_log (service_id, checked_at DESC); +CREATE INDEX idx_health_log_daily_uptime ON snp_service_health_log (service_id, checked_at, current_status); -- ----------------------------------------------------------- -- 5. snp_service_api (서비스 API) diff --git a/docs/schema/health_log_partition_migration.sql b/docs/schema/health_log_partition_migration.sql new file mode 100644 index 0000000..884f4c4 --- /dev/null +++ b/docs/schema/health_log_partition_migration.sql @@ -0,0 +1,72 @@ +-- ============================================================= +-- SNP Connection Monitoring - Health Log 파티션 마이그레이션 +-- snp_service_health_log → 일별 Range 파티션 전환 +-- 스키마: common +-- ============================================================= + +-- 1. 백업 +CREATE TABLE common.snp_service_health_log_backup AS +SELECT * FROM common.snp_service_health_log; + +-- 2. 기존 테이블 삭제 +DROP TABLE common.snp_service_health_log; + +-- 3. 파티션 테이블 생성 +CREATE TABLE common.snp_service_health_log ( + log_id BIGSERIAL, + service_id BIGINT NOT NULL, + previous_status VARCHAR(10), + current_status VARCHAR(10) NOT NULL, + response_time INTEGER, + error_message TEXT, + checked_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (log_id, checked_at) +) PARTITION BY RANGE (checked_at); + +-- 4. 인덱스 (파티션에 자동 상속) +CREATE INDEX idx_snp_health_log_service ON common.snp_service_health_log (service_id); +CREATE INDEX idx_snp_health_log_checked ON common.snp_service_health_log (checked_at); +CREATE INDEX idx_health_log_svc_checked ON common.snp_service_health_log (service_id, checked_at DESC); +CREATE INDEX idx_health_log_daily_uptime ON common.snp_service_health_log (service_id, checked_at, current_status); + +-- 5. 오늘 + 미래 7일 파티션 생성 (실행 시점에 맞게 날짜 수정) +-- 예시: 2026-04-13 실행 기준 +CREATE TABLE common.snp_service_health_log_20260413 + PARTITION OF common.snp_service_health_log + FOR VALUES FROM ('2026-04-13') TO ('2026-04-14'); +CREATE TABLE common.snp_service_health_log_20260414 + PARTITION OF common.snp_service_health_log + FOR VALUES FROM ('2026-04-14') TO ('2026-04-15'); +CREATE TABLE common.snp_service_health_log_20260415 + PARTITION OF common.snp_service_health_log + FOR VALUES FROM ('2026-04-15') TO ('2026-04-16'); +CREATE TABLE common.snp_service_health_log_20260416 + PARTITION OF common.snp_service_health_log + FOR VALUES FROM ('2026-04-16') TO ('2026-04-17'); +CREATE TABLE common.snp_service_health_log_20260417 + PARTITION OF common.snp_service_health_log + FOR VALUES FROM ('2026-04-17') TO ('2026-04-18'); +CREATE TABLE common.snp_service_health_log_20260418 + PARTITION OF common.snp_service_health_log + FOR VALUES FROM ('2026-04-18') TO ('2026-04-19'); +CREATE TABLE common.snp_service_health_log_20260419 + PARTITION OF common.snp_service_health_log + FOR VALUES FROM ('2026-04-19') TO ('2026-04-20'); +CREATE TABLE common.snp_service_health_log_20260420 + PARTITION OF common.snp_service_health_log + FOR VALUES FROM ('2026-04-20') TO ('2026-04-21'); + +-- 6. 데이터 복원 (컬럼 명시) +INSERT INTO common.snp_service_health_log ( + log_id, service_id, previous_status, current_status, response_time, error_message, checked_at +) SELECT + log_id, service_id, previous_status, current_status, response_time, error_message, checked_at +FROM common.snp_service_health_log_backup +WHERE checked_at >= '2026-04-13'; + +-- 7. 시퀀스 리셋 +SELECT setval(pg_get_serial_sequence('common.snp_service_health_log', 'log_id'), + (SELECT COALESCE(MAX(log_id), 0) FROM common.snp_service_health_log)); + +-- 8. 백업 삭제 (확인 후) +-- DROP TABLE common.snp_service_health_log_backup; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f6a8c8..c9d77ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import ThemeProvider from './store/ThemeContext'; import AuthProvider from './store/AuthContext'; -import AuthLayout from './layouts/AuthLayout'; import MainLayout from './layouts/MainLayout'; -import ProtectedRoute from './components/ProtectedRoute'; -import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; import RequestLogsPage from './pages/monitoring/RequestLogsPage'; import RequestLogDetailPage from './pages/monitoring/RequestLogDetailPage'; @@ -22,6 +19,7 @@ import ApiStatsPage from './pages/statistics/ApiStatsPage'; import TenantStatsPage from './pages/statistics/TenantStatsPage'; import UsageTrendPage from './pages/statistics/UsageTrendPage'; import NotFoundPage from './pages/NotFoundPage'; +import RoleGuard from './components/RoleGuard'; const BASE_PATH = '/snp-connection'; @@ -31,31 +29,25 @@ const App = () => { - }> - } /> - - - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index e2e53b0..c20e6c4 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,22 +1,5 @@ -import { Navigate, Outlet } from 'react-router-dom'; -import { useAuth } from '../hooks/useAuth'; +import { Outlet } from 'react-router-dom'; -const ProtectedRoute = () => { - const { isAuthenticated, isLoading } = useAuth(); - - if (isLoading) { - return ( -
-
-
- ); - } - - if (!isAuthenticated) { - return ; - } - - return ; -}; +const ProtectedRoute = () => ; export default ProtectedRoute; diff --git a/frontend/src/components/RoleGuard.tsx b/frontend/src/components/RoleGuard.tsx new file mode 100644 index 0000000..89b3a67 --- /dev/null +++ b/frontend/src/components/RoleGuard.tsx @@ -0,0 +1,41 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +interface RoleGuardProps { + allowedRoles: string[]; + children: React.ReactNode; +} + +const RoleGuard = ({ allowedRoles, children }: RoleGuardProps) => { + const { user } = useAuth(); + const navigate = useNavigate(); + + if (!user || !allowedRoles.includes(user.role)) { + return ( +
+
+
+ + + +
+

접근 권한이 없습니다

+

+ 이 페이지는 {allowedRoles.join(', ')} 권한이 필요합니다. +
현재 권한: {user?.role || '-'} +

+ +
+
+ ); + } + + return <>{children}; +}; + +export default RoleGuard; diff --git a/frontend/src/layouts/AuthLayout.tsx b/frontend/src/layouts/AuthLayout.tsx deleted file mode 100644 index 5b658cd..0000000 --- a/frontend/src/layouts/AuthLayout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Outlet } from 'react-router-dom'; - -const AuthLayout = () => { - return ( -
- -
- ); -}; - -export default AuthLayout; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 6c0a812..64d1a23 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -46,8 +46,10 @@ const navGroups: NavGroup[] = [ }, ]; +const ROLES = ['ADMIN', 'MANAGER', 'USER'] as const; + const MainLayout = () => { - const { user, logout } = useAuth(); + const { user, setRole } = useAuth(); const { theme, toggleTheme } = useTheme(); const [openGroups, setOpenGroups] = useState>({ Monitoring: true, @@ -60,10 +62,6 @@ const MainLayout = () => { setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] })); }; - const handleLogout = async () => { - await logout(); - }; - const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER'; return ( @@ -170,16 +168,22 @@ const MainLayout = () => { )} - {user?.userName} - - {user?.role} - - + Role: +
+ {ROLES.map((role) => ( + + ))} +
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx deleted file mode 100644 index a5ea345..0000000 --- a/frontend/src/pages/LoginPage.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from 'react'; -import type { FormEvent } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../hooks/useAuth'; - -const LoginPage = () => { - const navigate = useNavigate(); - const { login } = useAuth(); - const [loginId, setLoginId] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(''); - - if (!loginId.trim() || !password.trim()) { - setError('아이디와 비밀번호를 입력해주세요.'); - return; - } - - setIsSubmitting(true); - try { - await login(loginId, password); - navigate('/dashboard', { replace: true }); - } catch (err) { - if (err instanceof Error) { - setError(err.message); - } else { - setError('로그인에 실패했습니다.'); - } - } finally { - setIsSubmitting(false); - } - }; - - return ( -
-
-

- SNP Connection Monitoring -

- -
-
- - setLoginId(e.target.value)} - className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" - placeholder="아이디를 입력하세요" - autoComplete="username" - /> -
- -
- - setPassword(e.target.value)} - className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" - placeholder="비밀번호를 입력하세요" - autoComplete="current-password" - /> -
- - {error && ( -

{error}

- )} - - -
-
-
- ); -}; - -export default LoginPage; diff --git a/frontend/src/pages/monitoring/RequestLogsPage.tsx b/frontend/src/pages/monitoring/RequestLogsPage.tsx index 3e0e6c3..0e889dd 100644 --- a/frontend/src/pages/monitoring/RequestLogsPage.tsx +++ b/frontend/src/pages/monitoring/RequestLogsPage.tsx @@ -26,14 +26,13 @@ const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE']; const DEFAULT_PAGE_SIZE = 20; -const getTodayString = (): string => { - const d = new Date(); - const year = d.getFullYear(); - const month = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; +const formatDate = (d: Date): string => { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; +const getToday = (): string => formatDate(new Date()); +const getTodayString = getToday; + const formatDateTime = (dateStr: string): string => { const d = new Date(dateStr); const year = d.getFullYear(); @@ -50,10 +49,10 @@ const RequestLogsPage = () => { const [startDate, setStartDate] = useState(getTodayString()); const [endDate, setEndDate] = useState(getTodayString()); + const [datePreset, setDatePreset] = useState('오늘'); const [serviceId, setServiceId] = useState(''); const [requestStatus, setRequestStatus] = useState(''); const [requestMethod, setRequestMethod] = useState(''); - const [requestIp, setRequestIp] = useState(''); const [services, setServices] = useState([]); const [result, setResult] = useState | null>(null); @@ -84,7 +83,6 @@ const RequestLogsPage = () => { serviceId: serviceId ? Number(serviceId) : undefined, requestStatus: requestStatus || undefined, requestMethod: requestMethod || undefined, - requestIp: requestIp || undefined, page, size: DEFAULT_PAGE_SIZE, }; @@ -104,10 +102,10 @@ const RequestLogsPage = () => { const handleReset = () => { setStartDate(getTodayString()); setEndDate(getTodayString()); + setDatePreset('오늘'); setServiceId(''); setRequestStatus(''); setRequestMethod(''); - setRequestIp(''); setCurrentPage(0); }; @@ -169,45 +167,68 @@ const RequestLogsPage = () => { {/* Search Form */}
-
+
+
+ {([ + { label: '오늘', fn: () => { const t = getToday(); setStartDate(t); setEndDate(t); setDatePreset('오늘'); } }, + { label: '어제', fn: () => { const d = new Date(); d.setDate(d.getDate() - 1); const y = formatDate(d); setStartDate(y); setEndDate(y); setDatePreset('어제'); } }, + { label: '최근 7일', fn: () => { const d = new Date(); d.setDate(d.getDate() - 6); setStartDate(formatDate(d)); setEndDate(getToday()); setDatePreset('최근 7일'); } }, + { label: '이번 달', fn: () => { const d = new Date(); setStartDate(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`); setEndDate(getToday()); setDatePreset('이번 달'); } }, + { label: '지난 달', fn: () => { const d = new Date(); d.setMonth(d.getMonth() - 1); const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; const e = new Date(d.getFullYear(), d.getMonth() + 1, 0); setStartDate(s); setEndDate(formatDate(e)); setDatePreset('지난 달'); } }, + { label: '직접 선택', fn: () => { setDatePreset('직접 선택'); } }, + ]).map((btn) => ( + + ))} +
setStartDate(e.target.value)} + onChange={(e) => { setStartDate(e.target.value); setDatePreset('직접 선택'); }} className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" /> ~ setEndDate(e.target.value)} + onChange={(e) => { setEndDate(e.target.value); setDatePreset('직접 선택'); }} className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" />
+
+
- +
- +
-
-
- +
-
- - setRequestIp(e.target.value)} - placeholder="IP 주소" - className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" - /> -
-
+