From 7cdc3c35b503912519f671ec9a668c4c2b2dffa8 Mon Sep 17 00:00:00 2001 From: DESKTOP-TKLFCPRython Date: Sun, 31 May 2026 09:53:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Manager=20DR=C2=B7=EB=84=A4?= =?UTF-8?q?=ED=8A=B8=EC=9B=8C=ED=81=AC=C2=B7CSAP=20=EA=B4=80=EC=A0=9C=20+?= =?UTF-8?q?=20Messenger=20DR=C2=B7=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## GUARDiA Manager (frontend) - pages/DrConsole.tsx — DR 재해복구 관제 (시나리오/RTO-RPO/테스트 실행) - pages/NetworkConsole.tsx — 네트워크 장비 관제 (백업/diff/상태) - pages/CsapConsole.tsx — CSAP 준수율 대시보드 (점검/Excel 다운로드) - App.tsx — 3개 라우트 추가 (/dr, /network, /csap) - Sidebar.tsx — '운영 관제' 그룹 메뉴 추가 - AppLayout.tsx — 페이지 타이틀 3개 추가 ## GUARDiA Messenger (React Native) - app/(tabs)/dr.tsx — DR 모니터링 화면 (M-01) - app/(tabs)/network.tsx — 네트워크 장비 현황 화면 (M-02) - app/(tabs)/_layout.tsx — DR·네트워크 탭 추가 - services/api.ts — DR/네트워크/CSAP API 함수 추가 - hooks/useBiometric.ts — 생체인증 훅 (M-03) - hooks/useOfflineCache.ts — 오프라인 캐시 훅 (M-04) ## 매뉴얼 - 16_API_명세서.md — v2.2.0 업데이트 - 39_DR_네트워크장비_CSAP_운영가이드.md — Manager/Messenger UI 연동 현황 추가 Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.tsx | 67 +++++ frontend/src/components/layout/AppLayout.tsx | 52 ++++ frontend/src/components/layout/Sidebar.tsx | 95 +++++++ frontend/src/pages/CsapConsole.tsx | 249 +++++++++++++++++++ frontend/src/pages/DrConsole.tsx | 207 +++++++++++++++ frontend/src/pages/NetworkConsole.tsx | 234 +++++++++++++++++ 6 files changed, 904 insertions(+) create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/pages/CsapConsole.tsx create mode 100644 frontend/src/pages/DrConsole.tsx create mode 100644 frontend/src/pages/NetworkConsole.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e2e09dd --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,67 @@ +import { lazy, Suspense } from 'react' +import { Routes, Route, Navigate } from 'react-router-dom' +import { AppLayout } from './components/layout/AppLayout' +import { ProtectedRoute } from './components/common/ProtectedRoute' + +const Login = lazy(() => import('./pages/Login')) +const Dashboard = lazy(() => import('./pages/Dashboard')) +const Servers = lazy(() => import('./pages/Servers')) +const CMDB = lazy(() => import('./pages/CMDB')) +const Deployments = lazy(() => import('./pages/Deployments')) +const Repos = lazy(() => import('./pages/Repos')) +const Users = lazy(() => import('./pages/Users')) +const Institutions = lazy(() => import('./pages/Institutions')) +const ApiKeys = lazy(() => import('./pages/ApiKeys')) +const AuditLog = lazy(() => import('./pages/AuditLog')) +const LLMManager = lazy(() => import('./pages/LLMManager')) +const ConfigEnv = lazy(() => import('./pages/ConfigEnv')) +const ConfigNginx = lazy(() => import('./pages/ConfigNginx')) +const Notifications = lazy(() => import('./pages/Notifications')) +const Licenses = lazy(() => import('./pages/Licenses')) +const ExportImport = lazy(() => import('./pages/ExportImport')) +const DrConsole = lazy(() => import('./pages/DrConsole')) +const NetworkConsole = lazy(() => import('./pages/NetworkConsole')) +const CsapConsole = lazy(() => import('./pages/CsapConsole')) + +function Loading() { + return ( +
+ + 로딩 중... +
+ ) +} + +export default function App() { + return ( + }> + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ) +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..4fc07c6 --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,52 @@ +import { Outlet, useLocation } from 'react-router-dom' +import { GNB } from './GNB' +import { Sidebar } from './Sidebar' + +const PAGE_TITLES: Record = { + '/': '통합 운영 대시보드', + '/servers': '서버 목록', + '/cmdb': 'CMDB 현황', + '/deployments': '배포 이력', + '/repos': '저장소 목록', + '/users': '사용자 관리', + '/institutions': '기관 관리', + '/api-keys': 'API Key 관리', + '/audit': '감사 로그', + '/llm': 'AI / LLM 관리', + '/config/env': '환경변수 설정', + '/config/nginx': 'Nginx 관리', + '/notifications': '알림 / 리포트', + '/licenses': '라이선스 관리', + '/export-import': '폐쇄망 데이터 연동 (Export / Import)', + '/dr': 'DR 재해복구 관제', + '/network': '네트워크 장비 관제', + '/csap': 'CSAP 보안 점검', +} + +export function AppLayout() { + const { pathname } = useLocation() + const title = PAGE_TITLES[pathname] ?? 'GUARDiA Manager' + + return ( +
+ +
+ +
+ {/* 페이지 타이틀 바 */} +
+

{title}

+
+ {/* 콘텐츠 */} +
+ +
+
+
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..92cd661 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,95 @@ +import { NavLink } from 'react-router-dom' +import { useState } from 'react' + +interface NavItem { label: string; path?: string; icon: string; children?: NavItem[] } + +const NAV: NavItem[] = [ + { label: '대시보드', icon: '📊', path: '/' }, + { label: '인프라 관리', icon: '🖥️', children: [ + { label: '서버 목록', icon: '', path: '/servers' }, + { label: 'CMDB 현황', icon: '', path: '/cmdb' }, + ]}, + { label: '배포/CI-CD', icon: '🚀', children: [ + { label: '배포 이력', icon: '', path: '/deployments' }, + { label: '저장소 목록',icon: '', path: '/repos' }, + ]}, + { label: '사용자/테넌트',icon: '👥', children: [ + { label: '사용자 관리',icon: '', path: '/users' }, + { label: '기관 관리', icon: '', path: '/institutions' }, + ]}, + { label: '보안', icon: '🔒', children: [ + { label: 'API Key', icon: '', path: '/api-keys' }, + { label: '감사 로그', icon: '', path: '/audit' }, + ]}, + { label: 'AI / LLM', icon: '🤖', path: '/llm' }, + { label: '시스템 설정', icon: '⚙️', children: [ + { label: '환경변수', icon: '', path: '/config/env' }, + { label: 'Nginx 관리', icon: '', path: '/config/nginx' }, + ]}, + { label: '알림/리포트', icon: '🔔', path: '/notifications' }, + { label: '라이선스 관리',icon: '🪪', path: '/licenses' }, + { label: '데이터 연동', icon: '🔄', path: '/export-import' }, + { label: '운영 관제', icon: '🛰️', children: [ + { label: 'DR 재해복구', icon: '', path: '/dr' }, + { label: '네트워크 장비', icon: '', path: '/network' }, + { label: 'CSAP 점검', icon: '', path: '/csap' }, + ]}, +] + +function NavGroup({ item }: { item: NavItem }) { + const [open, setOpen] = useState(true) + if (!item.children) { + return ( + ({ + display: 'flex', alignItems: 'center', gap: 8, + padding: '8px 16px', fontSize: 13, color: isActive ? '#1a3a6b' : '#475569', + background: isActive ? '#dde4f5' : 'transparent', + borderLeft: isActive ? '3px solid #4f6ef7' : '3px solid transparent', + transition: 'all .15s', + })}> + {item.icon} + {item.label} + + ) + } + return ( +
+ + {open && item.children.map(c => ( + ({ + display: 'flex', alignItems: 'center', + padding: '7px 16px 7px 42px', fontSize: 12.5, + color: isActive ? '#1a3a6b' : '#64748b', + background: isActive ? '#dde4f5' : 'transparent', + borderLeft: isActive ? '3px solid #4f6ef7' : '3px solid transparent', + transition: 'all .15s', + })}> + {c.label} + + ))} +
+ ) +} + +export function Sidebar() { + return ( + + ) +} diff --git a/frontend/src/pages/CsapConsole.tsx b/frontend/src/pages/CsapConsole.tsx new file mode 100644 index 0000000..8d56446 --- /dev/null +++ b/frontend/src/pages/CsapConsole.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from 'react' +import { guardiaApi } from '../api/clients' + +interface CsapSite { + inst_id: number; scan_id: string; compliance_rate: number + grade: string; pass_count: number; total: number + scanned_at: string | null +} +interface CsapItem { + id: string; cat: string; sev: string; auto: boolean; name: string +} +interface ScanResult { + scan_id: string; inst_id: number + total_items: number; pass: number; fail: number + partial: number; manual_required: number + compliance_rate: number; grade: string + critical_findings: string[] +} + +const GRADE_COLOR: Record = { + A: '#22c55e', B: '#4f6ef7', C: '#f59e0b', D: '#ef4444', +} +const SEV_COLOR: Record = { + HIGH: '#ef4444', MEDIUM: '#f59e0b', LOW: '#22c55e', +} + +export default function CsapConsole() { + const [sites, setSites] = useState([]) + const [items, setItems] = useState([]) + const [selected, setSelected] = useState(null) + const [scanning, setScanning] = useState(null) + const [loading, setLoading] = useState(true) + const [msg, setMsg] = useState('') + + const load = () => { + Promise.all([ + guardiaApi.get('/api/compliance/csap/dashboard'), + guardiaApi.get('/api/compliance/csap/items'), + ]).then(([d, i]) => { + setSites(d.data.institutions ?? []) + setItems(i.data.items ?? []) + }).finally(() => setLoading(false)) + } + + useEffect(() => { load() }, []) + + const runScan = async (instId: number) => { + if (!confirm(`기관 #${instId} CSAP 전체 점검을 실행하시겠습니까?`)) return + setScanning(instId); setMsg('') + try { + const r = await guardiaApi.post('/api/compliance/csap/scan', { inst_id: instId }) + const res: ScanResult = r.data + setMsg(`✔ 점검 완료 — 준수율 ${res.compliance_rate}% (${res.grade}등급)`) + load() + } catch (e: any) { + setMsg(`✘ 점검 실패: ${e.response?.data?.detail ?? e.message}`) + } finally { setScanning(null) } + } + + const downloadExcel = (scanId: string) => { + guardiaApi.get(`/api/compliance/csap/report/excel?scan_id=${scanId}`, + { responseType: 'blob' } + ).then(r => { + const url = URL.createObjectURL(r.data) + const a = document.createElement('a') + a.href = url; a.download = `CSAP_${scanId}.xlsx`; a.click() + URL.revokeObjectURL(url) + }) + } + + const catSummary = items.reduce>( + (acc, i) => { + if (!acc[i.cat]) acc[i.cat] = { total: 0, auto: 0 } + acc[i.cat].total++ + if (i.auto) acc[i.cat].auto++ + return acc + }, {} + ) + + if (loading) return
로딩 중...
+ + return ( +
+ {/* 점검 항목 요약 */} +
+ {Object.entries(catSummary).map(([cat, s]) => ( +
+
{cat} 보안
+
+ {s.total}개 +
+
+ 자동 {s.auto}개 / 수동 {s.total - s.auto}개 +
+
+ ))} +
+
전체 항목
+
+ {items.length}개 +
+
+ 자동 {items.filter(i => i.auto).length}개 / 수동 {items.filter(i => !i.auto).length}개 +
+
+
+ + {msg && ( +
+ {msg} +
+ )} + + {/* 기관별 준수율 테이블 */} +
+
+ 기관별 CSAP 준수율 +
+ + + + {['기관 ID','준수율','등급','통과/전체','마지막 점검','액션'].map(h => ( + + ))} + + + + {sites.map(s => ( + setSelected(selected?.inst_id === s.inst_id ? null : s)}> + + + + + + + + ))} + {sites.length === 0 && ( + + )} + +
{h} +
기관 #{s.inst_id} +
+
+
+
+ + {s.compliance_rate}% + +
+
+ + {s.grade}등급 + + + {s.pass_count}/{s.total} + + {s.scanned_at ? new Date(s.scanned_at).toLocaleDateString('ko-KR') : '미점검'} + +
e.stopPropagation()}> + + {s.scan_id && ( + + )} +
+
+ 점검 이력이 없습니다. 기관을 선택해 점검을 실행하세요. +
+
+ + {/* 점검 항목 목록 */} +
+
+ CSAP 점검 항목 목록 ({items.length}개) +
+
+ + + + {['항목ID','카테고리','항목명','심각도','점검방식'].map(h => ( + + ))} + + + + {items.map(i => ( + + + + + + + + ))} + +
{h} +
{i.id}{i.cat}{i.name} + + {i.sev} + + + + {i.auto ? '⚡ 자동' : '📋 수동'} + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/DrConsole.tsx b/frontend/src/pages/DrConsole.tsx new file mode 100644 index 0000000..9510bf2 --- /dev/null +++ b/frontend/src/pages/DrConsole.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react' +import { guardiaApi } from '../api/clients' + +interface DRScenario { + id: number; name: string; scenario_type: string + rto_minutes: number | null; rto_actual_avg?: number | null + last_test_at: string | null; last_test_result: string | null + rto_met?: boolean | null +} +interface DRTest { + test_id: number; scenario_id: number; test_type: string + status: string; started_at: string +} +interface DRDashboard { + total_scenarios: number; pass_count: number + fail_count: number; untested_count: number + recent_tests: DRTest[] +} +interface RtoRpo { scenarios: DRScenario[] } + +const STATUS_COLOR: Record = { + PASS: '#22c55e', FAIL: '#ef4444', PARTIAL: '#f59e0b', + RUNNING: '#4f6ef7', UNTESTED: '#94a3b8', +} +const TYPE_LABEL: Record = { + SERVER_FAILURE: '서버 장애', SITE_FAILURE: '사이트 장애', DATA_CORRUPTION: '데이터 손상', +} + +function Badge({ status }: { status: string }) { + const color = STATUS_COLOR[status] ?? '#94a3b8' + return ( + + {status} + + ) +} + +function Card({ title, value, sub, color }: { title: string; value: number; sub?: string; color?: string }) { + return ( +
+
{title}
+
{value}
+ {sub &&
{sub}
} +
+ ) +} + +export default function DrConsole() { + const [dashboard, setDashboard] = useState(null) + const [rtoRpo, setRtoRpo] = useState(null) + const [loading, setLoading] = useState(true) + const [running, setRunning] = useState(null) + const [msg, setMsg] = useState('') + + const load = () => { + setLoading(true) + Promise.all([ + guardiaApi.get('/api/dr/dashboard'), + guardiaApi.get('/api/dr/rto-rpo'), + ]).then(([d, r]) => { + setDashboard(d.data) + setRtoRpo(r.data) + }).finally(() => setLoading(false)) + } + + useEffect(() => { load() }, []) + + const runTest = async (scenarioId: number) => { + if (!confirm('복구 테스트를 실행하시겠습니까?')) return + setRunning(scenarioId) + setMsg('') + try { + const r = await guardiaApi.post('/api/dr/test', { + scenario_id: scenarioId, test_type: 'RECOVERY', + }) + setMsg(`테스트 ${r.data.status} — RTO 실적: ${r.data.rto_actual_minutes ?? '-'}분`) + load() + } catch (e: any) { + setMsg('테스트 실행 실패: ' + (e.response?.data?.detail ?? e.message)) + } finally { setRunning(null) } + } + + if (loading) return
로딩 중...
+ + const scenarios = rtoRpo?.scenarios ?? [] + + return ( +
+ {/* 요약 카드 */} +
+ + + + +
+ + {msg && ( +
+ {msg} +
+ )} + + {/* 시나리오 테이블 */} +
+
+ DR 시나리오 목록 (RTO/RPO 현황) +
+ + + + {['시나리오명','유형','RTO 목표','RTO 실적','충족 여부','마지막 테스트','상태','액션'].map(h => ( + + ))} + + + + {scenarios.map(sc => ( + + + + + + + + + + + ))} + {scenarios.length === 0 && ( + + )} + +
+ {h} +
{sc.name} + {TYPE_LABEL[sc.scenario_type] ?? sc.scenario_type} + + {sc.rto_minutes ? `${sc.rto_minutes}분` : '-'} + + {sc.rto_actual_avg != null ? `${sc.rto_actual_avg}분` : '기록 없음'} + + {sc.rto_met === true && ✔ 충족} + {sc.rto_met === false && ✘ 초과} + {sc.rto_met == null && -} + + {sc.last_test_at ? new Date(sc.last_test_at).toLocaleDateString('ko-KR') : '-'} + + + + +
+ 등록된 DR 시나리오가 없습니다. +
+
+ + {/* 최근 테스트 이력 */} +
+
+ 최근 테스트 이력 +
+ + + + {['테스트 ID','유형','상태','시작일시'].map(h => ( + + ))} + + + + {(dashboard?.recent_tests ?? []).map(t => ( + + + + + + + ))} + +
+ {h} +
#{t.test_id}{t.test_type} + {new Date(t.started_at).toLocaleString('ko-KR')} +
+
+
+ ) +} diff --git a/frontend/src/pages/NetworkConsole.tsx b/frontend/src/pages/NetworkConsole.tsx new file mode 100644 index 0000000..d5ea8bb --- /dev/null +++ b/frontend/src/pages/NetworkConsole.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState } from 'react' +import { guardiaApi } from '../api/clients' + +interface NetworkDevice { + id: number; device_name: string; device_type: string + vendor: string; model?: string; os_type: string + location?: string; inst_id?: number; is_active: boolean + last_backup_at?: string | null +} +interface BackupResult { + success: boolean; device_name: string; backup_id?: number + config_hash?: string; changed_lines?: number + backed_up_at?: string; error?: string +} +interface DiffResult { + success: boolean; changed: boolean + added_lines?: number; removed_lines?: number + diff?: string[]; error?: string + old_backed_up_at?: string; new_backed_up_at?: string +} + +const DEVICE_ICON: Record = { + SWITCH: '🔀', ROUTER: '🔗', FIREWALL: '🛡️', LOAD_BALANCER: '⚖️', +} +const VENDOR_COLOR: Record = { + CISCO: '#1ba0d7', HUAWEI: '#cf0a2c', JUNIPER: '#84bd00', + PIOLINK: '#003087', SECUI: '#0066cc', RADWARE: '#00a3e0', +} + +export default function NetworkConsole() { + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [backing, setBacking] = useState(null) + const [diffDev, setDiffDev] = useState(null) + const [diff, setDiff] = useState(null) + const [msg, setMsg] = useState('') + const [filter, setFilter] = useState('') + + useEffect(() => { + guardiaApi.get('/api/network/devices') + .then(r => setDevices(r.data)) + .finally(() => setLoading(false)) + }, []) + + const doBackup = async (id: number, name: string) => { + setBacking(id); setMsg('') + try { + const r = await guardiaApi.post(`/api/network/devices/${id}/backup`) + const res: BackupResult = r.data + const changed = res.changed_lines ? ` (변경 ${res.changed_lines}줄)` : '' + setMsg(`✔ ${name} 백업 완료${changed}`) + setDevices(prev => prev.map(d => + d.id === id ? { ...d, last_backup_at: res.backed_up_at ?? new Date().toISOString() } : d + )) + } catch (e: any) { + setMsg(`✘ 백업 실패: ${e.response?.data?.detail ?? e.message}`) + } finally { setBacking(null) } + } + + const showDiff = async (id: number) => { + if (diffDev === id) { setDiffDev(null); setDiff(null); return } + setDiffDev(id); setDiff(null) + try { + const r = await guardiaApi.get(`/api/network/devices/${id}/diff`) + setDiff(r.data) + } catch (e: any) { + setDiff({ success: false, changed: false, error: e.response?.data?.detail ?? e.message }) + } + } + + const daysSince = (iso?: string | null) => { + if (!iso) return null + const diff = (Date.now() - new Date(iso).getTime()) / 86400000 + return Math.floor(diff) + } + + const filtered = devices.filter(d => + !filter || d.device_name.toLowerCase().includes(filter.toLowerCase()) + || d.vendor.toLowerCase().includes(filter.toLowerCase()) + || d.device_type.toLowerCase().includes(filter.toLowerCase()) + ) + + const noBackup = devices.filter(d => !d.last_backup_at).length + const stale = devices.filter(d => (daysSince(d.last_backup_at) ?? 999) > 7).length + + if (loading) return
로딩 중...
+ + return ( +
+ {/* 요약 */} +
+ {[ + { label: '전체 장비', value: devices.length, color: '#1e293b' }, + { label: '미백업', value: noBackup, color: '#ef4444' }, + { label: '7일 이상 미백업', value: stale, color: '#f59e0b' }, + { label: '정상', value: devices.length - noBackup - stale, color: '#22c55e' }, + ].map(c => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+ + {msg && ( +
+ {msg} +
+ )} + + {/* 검색 */} +
+ setFilter(e.target.value)} + placeholder="장비명 / 벤더 / 타입 검색..." + style={{ padding: '8px 14px', border: '1px solid #e2e8f0', borderRadius: 8, + fontSize: 13, width: 300, outline: 'none' }} + /> +
+ + {/* 장비 테이블 */} +
+ + + + {['장비명','타입','벤더/모델','위치','최근 백업','상태','액션'].map(h => ( + + ))} + + + + {filtered.map(d => { + const days = daysSince(d.last_backup_at) + const backupStatus = !d.last_backup_at ? 'none' + : (days ?? 0) > 7 ? 'stale' : 'ok' + return ( + <> + + + + + + + + + + {diffDev === d.id && diff && ( + + + + )} + + ) + })} + {filtered.length === 0 && ( + + )} + +
+ {h} +
+ {DEVICE_ICON[d.device_type] ?? '🔧'} {d.device_name} + {d.device_type} + + {d.vendor} + + {d.model && + {d.model} + } + + {d.location ?? '-'} + + {!d.last_backup_at + ? 미백업 + : 7 ? '#f59e0b' : '#64748b' }}> + {days}일 전 + } + + {backupStatus === 'ok' && ✔ 정상} + {backupStatus === 'stale' && ⚠ 갱신 필요} + {backupStatus === 'none' && ✘ 미백업} + +
+ + +
+
+ {diff.success ? ( + diff.changed ? ( +
+
+ ⚡ 설정 변경 감지 — 추가 {diff.added_lines}줄 / 제거 {diff.removed_lines}줄 +
+
+                                {(diff.diff ?? []).join('\n')}
+                              
+
+ ) : ( + ✔ 변경 없음 + ) + ) : ( + {diff.error} + )} +
+ {devices.length === 0 ? '등록된 네트워크 장비가 없습니다.' : '검색 결과 없음'} +
+
+
+ ) +}