feat(ui): Manager DR·네트워크·CSAP 관제 + Messenger DR·네트워크 화면 구현

## 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 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPRython 2026-05-31 09:53:17 +09:00
parent af6077e216
commit 7cdc3c35b5
6 changed files with 904 additions and 0 deletions

67
frontend/src/App.tsx Normal file
View File

@ -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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '60vh', color: '#94a3b8', gap: 10 }}>
<span style={{ width: 16, height: 16, border: '2px solid #4f6ef7',
borderTopColor: 'transparent', borderRadius: '50%',
animation: 'spin .6s linear infinite', display: 'inline-block' }} />
...
</div>
)
}
export default function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route index element={<Dashboard />} />
<Route path="servers" element={<Servers />} />
<Route path="cmdb" element={<CMDB />} />
<Route path="deployments" element={<Deployments />} />
<Route path="repos" element={<Repos />} />
<Route path="users" element={<Users />} />
<Route path="institutions" element={<Institutions />} />
<Route path="api-keys" element={<ApiKeys />} />
<Route path="audit" element={<AuditLog />} />
<Route path="llm" element={<LLMManager />} />
<Route path="config/env" element={<ConfigEnv />} />
<Route path="config/nginx" element={<ConfigNginx />} />
<Route path="notifications" element={<Notifications />} />
<Route path="licenses" element={<Licenses />} />
<Route path="export-import" element={<ExportImport />} />
<Route path="dr" element={<DrConsole />} />
<Route path="network" element={<NetworkConsole />} />
<Route path="csap" element={<CsapConsole />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
)
}

View File

@ -0,0 +1,52 @@
import { Outlet, useLocation } from 'react-router-dom'
import { GNB } from './GNB'
import { Sidebar } from './Sidebar'
const PAGE_TITLES: Record<string, string> = {
'/': '통합 운영 대시보드',
'/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 (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<GNB />
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<Sidebar />
<main style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* 페이지 타이틀 바 */}
<div style={{
padding: '14px 28px', background: '#fff',
borderBottom: '1px solid #e2e8f0',
display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0,
}}>
<h1 style={{ fontSize: 16, fontWeight: 700, color: '#1e293b', margin: 0 }}>{title}</h1>
</div>
{/* 콘텐츠 */}
<div style={{ padding: 24, flex: 1 }} className="animate-in">
<Outlet />
</div>
</main>
</div>
</div>
)
}

View File

@ -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 (
<NavLink to={item.path!} style={({ isActive }) => ({
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',
})}>
<span style={{ width: 18, textAlign: 'center' }}>{item.icon}</span>
{item.label}
</NavLink>
)
}
return (
<div>
<button onClick={() => setOpen(o => !o)} style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', fontSize: 13, color: '#1e293b', background: 'none',
border: 'none', cursor: 'pointer', fontWeight: 600,
}}>
<span style={{ width: 18, textAlign: 'center' }}>{item.icon}</span>
{item.label}
<span style={{ marginLeft: 'auto', fontSize: 10, transition: 'transform .2s',
transform: open ? 'rotate(180deg)' : 'rotate(0)' }}></span>
</button>
{open && item.children.map(c => (
<NavLink key={c.path} to={c.path!} style={({ isActive }) => ({
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}
</NavLink>
))}
</div>
)
}
export function Sidebar() {
return (
<aside style={{
width: 'var(--sidebar-w)', background: 'var(--sidebar-bg)',
borderRight: '1px solid var(--border)', height: '100%',
display: 'flex', flexDirection: 'column', overflowY: 'auto',
}}>
<div style={{ padding: '14px 0', flex: 1 }}>
{NAV.map((item, i) => <NavGroup key={i} item={item} />)}
</div>
</aside>
)
}

View File

@ -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<string, string> = {
A: '#22c55e', B: '#4f6ef7', C: '#f59e0b', D: '#ef4444',
}
const SEV_COLOR: Record<string, string> = {
HIGH: '#ef4444', MEDIUM: '#f59e0b', LOW: '#22c55e',
}
export default function CsapConsole() {
const [sites, setSites] = useState<CsapSite[]>([])
const [items, setItems] = useState<CsapItem[]>([])
const [selected, setSelected] = useState<CsapSite | null>(null)
const [scanning, setScanning] = useState<number | null>(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<Record<string, { total: number; auto: number }>>(
(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 <div style={{ color: '#94a3b8', padding: 40 }}> ...</div>
return (
<div>
{/* 점검 항목 요약 */}
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
{Object.entries(catSummary).map(([cat, s]) => (
<div key={cat} style={{ background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 10, padding: '14px 20px', flex: 1 }}>
<div style={{ fontSize: 12, color: '#64748b' }}>{cat} </div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#1e293b', marginTop: 4 }}>
{s.total}
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
{s.auto} / {s.total - s.auto}
</div>
</div>
))}
<div style={{ background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 10, padding: '14px 20px', flex: 1 }}>
<div style={{ fontSize: 12, color: '#64748b' }}> </div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#1e293b', marginTop: 4 }}>
{items.length}
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
{items.filter(i => i.auto).length} / {items.filter(i => !i.auto).length}
</div>
</div>
</div>
{msg && (
<div style={{ padding: '10px 16px', borderRadius: 8, marginBottom: 16,
background: msg.startsWith('✔') ? '#f0fdf4' : '#fef2f2',
color: msg.startsWith('✔') ? '#16a34a' : '#dc2626',
border: `1px solid ${msg.startsWith('✔') ? '#bbf7d0' : '#fecaca'}`,
fontSize: 13 }}>
{msg}
</div>
)}
{/* 기관별 준수율 테이블 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
overflow: 'hidden', marginBottom: 20 }}>
<div style={{ padding: '14px 20px', borderBottom: '1px solid #f1f5f9',
fontWeight: 600, fontSize: 13, color: '#1e293b' }}>
CSAP
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{['기관 ID','준수율','등급','통과/전체','마지막 점검','액션'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left',
fontWeight: 600, color: '#475569', fontSize: 12,
borderBottom: '1px solid #e2e8f0' }}>{h}
</th>
))}
</tr>
</thead>
<tbody>
{sites.map(s => (
<tr key={s.inst_id}
style={{ borderBottom: '1px solid #f1f5f9',
background: selected?.inst_id === s.inst_id ? '#f8fafc' : undefined,
cursor: 'pointer' }}
onClick={() => setSelected(selected?.inst_id === s.inst_id ? null : s)}>
<td style={{ padding: '10px 14px', color: '#64748b' }}> #{s.inst_id}</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, maxWidth: 120, height: 6, background: '#f1f5f9',
borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${s.compliance_rate}%`, height: '100%',
background: GRADE_COLOR[s.grade] ?? '#4f6ef7', borderRadius: 3 }} />
</div>
<span style={{ fontWeight: 700, color: GRADE_COLOR[s.grade] ?? '#1e293b',
minWidth: 40 }}>
{s.compliance_rate}%
</span>
</div>
</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ padding: '3px 12px', borderRadius: 12, fontSize: 12,
fontWeight: 700, color: '#fff',
background: GRADE_COLOR[s.grade] ?? '#94a3b8' }}>
{s.grade}
</span>
</td>
<td style={{ padding: '10px 14px', color: '#475569' }}>
{s.pass_count}/{s.total}
</td>
<td style={{ padding: '10px 14px', color: '#64748b', fontSize: 12 }}>
{s.scanned_at ? new Date(s.scanned_at).toLocaleDateString('ko-KR') : '미점검'}
</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', gap: 6 }} onClick={e => e.stopPropagation()}>
<button onClick={() => runScan(s.inst_id)}
disabled={scanning === s.inst_id}
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6,
background: scanning === s.inst_id ? '#e2e8f0' : '#4f6ef7',
color: scanning === s.inst_id ? '#94a3b8' : '#fff',
border: 'none', cursor: scanning === s.inst_id ? 'not-allowed' : 'pointer' }}>
{scanning === s.inst_id ? '점검 중..' : '점검'}
</button>
{s.scan_id && (
<button onClick={() => downloadExcel(s.scan_id)}
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6,
background: '#f1f5f9', color: '#475569',
border: '1px solid #e2e8f0', cursor: 'pointer' }}>
Excel
</button>
)}
</div>
</td>
</tr>
))}
{sites.length === 0 && (
<tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>
. .
</td></tr>
)}
</tbody>
</table>
</div>
{/* 점검 항목 목록 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
overflow: 'hidden' }}>
<div style={{ padding: '14px 20px', borderBottom: '1px solid #f1f5f9',
fontWeight: 600, fontSize: 13, color: '#1e293b' }}>
CSAP ({items.length})
</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#f8fafc', position: 'sticky', top: 0 }}>
{['항목ID','카테고리','항목명','심각도','점검방식'].map(h => (
<th key={h} style={{ padding: '8px 14px', textAlign: 'left',
fontWeight: 600, color: '#475569',
borderBottom: '1px solid #e2e8f0' }}>{h}
</th>
))}
</tr>
</thead>
<tbody>
{items.map(i => (
<tr key={i.id} style={{ borderBottom: '1px solid #f8fafc' }}>
<td style={{ padding: '7px 14px', fontWeight: 700, color: '#4f6ef7' }}>{i.id}</td>
<td style={{ padding: '7px 14px', color: '#64748b' }}>{i.cat}</td>
<td style={{ padding: '7px 14px', color: '#1e293b' }}>{i.name}</td>
<td style={{ padding: '7px 14px' }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 10,
fontWeight: 700, color: '#fff',
background: SEV_COLOR[i.sev] ?? '#94a3b8' }}>
{i.sev}
</span>
</td>
<td style={{ padding: '7px 14px' }}>
<span style={{ fontSize: 11, color: i.auto ? '#22c55e' : '#f59e0b',
fontWeight: 600 }}>
{i.auto ? '⚡ 자동' : '📋 수동'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@ -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<string, string> = {
PASS: '#22c55e', FAIL: '#ef4444', PARTIAL: '#f59e0b',
RUNNING: '#4f6ef7', UNTESTED: '#94a3b8',
}
const TYPE_LABEL: Record<string, string> = {
SERVER_FAILURE: '서버 장애', SITE_FAILURE: '사이트 장애', DATA_CORRUPTION: '데이터 손상',
}
function Badge({ status }: { status: string }) {
const color = STATUS_COLOR[status] ?? '#94a3b8'
return (
<span style={{ padding: '2px 10px', borderRadius: 12, fontSize: 11,
fontWeight: 700, color: '#fff', background: color }}>
{status}
</span>
)
}
function Card({ title, value, sub, color }: { title: string; value: number; sub?: string; color?: string }) {
return (
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
padding: '18px 22px', flex: 1 }}>
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: color ?? '#1e293b' }}>{value}</div>
{sub && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{sub}</div>}
</div>
)
}
export default function DrConsole() {
const [dashboard, setDashboard] = useState<DRDashboard | null>(null)
const [rtoRpo, setRtoRpo] = useState<RtoRpo | null>(null)
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState<number | null>(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 <div style={{ color: '#94a3b8', padding: 40 }}> ...</div>
const scenarios = rtoRpo?.scenarios ?? []
return (
<div>
{/* 요약 카드 */}
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
<Card title="전체 시나리오" value={dashboard?.total_scenarios ?? 0} />
<Card title="PASS" value={dashboard?.pass_count ?? 0} color="#22c55e" sub="최근 테스트 통과" />
<Card title="FAIL" value={dashboard?.fail_count ?? 0} color="#ef4444" sub="조치 필요" />
<Card title="미테스트" value={dashboard?.untested_count ?? 0} color="#f59e0b" sub="테스트 필요" />
</div>
{msg && (
<div style={{ padding: '10px 16px', borderRadius: 8, marginBottom: 16,
background: msg.includes('실패') ? '#fef2f2' : '#f0fdf4',
color: msg.includes('실패') ? '#dc2626' : '#16a34a',
border: `1px solid ${msg.includes('실패') ? '#fecaca' : '#bbf7d0'}`,
fontSize: 13 }}>
{msg}
</div>
)}
{/* 시나리오 테이블 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
marginBottom: 24, overflow: 'hidden' }}>
<div style={{ padding: '14px 20px', borderBottom: '1px solid #f1f5f9',
fontWeight: 600, fontSize: 13, color: '#1e293b' }}>
DR (RTO/RPO )
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{['시나리오명','유형','RTO 목표','RTO 실적','충족 여부','마지막 테스트','상태','액션'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left',
fontWeight: 600, color: '#475569', fontSize: 12, borderBottom: '1px solid #e2e8f0' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{scenarios.map(sc => (
<tr key={sc.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '10px 14px', fontWeight: 600, color: '#1e293b' }}>{sc.name}</td>
<td style={{ padding: '10px 14px', color: '#64748b' }}>
{TYPE_LABEL[sc.scenario_type] ?? sc.scenario_type}
</td>
<td style={{ padding: '10px 14px', color: '#475569' }}>
{sc.rto_minutes ? `${sc.rto_minutes}` : '-'}
</td>
<td style={{ padding: '10px 14px', fontWeight: 600,
color: sc.rto_actual_avg ? '#1e293b' : '#94a3b8' }}>
{sc.rto_actual_avg != null ? `${sc.rto_actual_avg}` : '기록 없음'}
</td>
<td style={{ padding: '10px 14px' }}>
{sc.rto_met === true && <span style={{ color:'#22c55e', fontWeight:700 }}> </span>}
{sc.rto_met === false && <span style={{ color:'#ef4444', fontWeight:700 }}> </span>}
{sc.rto_met == null && <span style={{ color:'#94a3b8' }}>-</span>}
</td>
<td style={{ padding: '10px 14px', color: '#64748b', fontSize: 12 }}>
{sc.last_test_at ? new Date(sc.last_test_at).toLocaleDateString('ko-KR') : '-'}
</td>
<td style={{ padding: '10px 14px' }}>
<Badge status={sc.last_test_result ?? 'UNTESTED'} />
</td>
<td style={{ padding: '10px 14px' }}>
<button
onClick={() => runTest(sc.id)}
disabled={running === sc.id}
style={{ padding: '5px 12px', fontSize: 12, borderRadius: 6,
background: running === sc.id ? '#e2e8f0' : '#4f6ef7',
color: running === sc.id ? '#94a3b8' : '#fff',
border: 'none', cursor: running === sc.id ? 'not-allowed' : 'pointer' }}>
{running === sc.id ? '실행 중...' : '테스트 실행'}
</button>
</td>
</tr>
))}
{scenarios.length === 0 && (
<tr><td colSpan={8} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>
DR .
</td></tr>
)}
</tbody>
</table>
</div>
{/* 최근 테스트 이력 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ padding: '14px 20px', borderBottom: '1px solid #f1f5f9',
fontWeight: 600, fontSize: 13, color: '#1e293b' }}>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{['테스트 ID','유형','상태','시작일시'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left',
fontWeight: 600, color: '#475569', fontSize: 12, borderBottom: '1px solid #e2e8f0' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{(dashboard?.recent_tests ?? []).map(t => (
<tr key={t.test_id} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '10px 14px', color: '#64748b' }}>#{t.test_id}</td>
<td style={{ padding: '10px 14px', color: '#475569' }}>{t.test_type}</td>
<td style={{ padding: '10px 14px' }}><Badge status={t.status} /></td>
<td style={{ padding: '10px 14px', color: '#64748b', fontSize: 12 }}>
{new Date(t.started_at).toLocaleString('ko-KR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -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<string, string> = {
SWITCH: '🔀', ROUTER: '🔗', FIREWALL: '🛡️', LOAD_BALANCER: '⚖️',
}
const VENDOR_COLOR: Record<string, string> = {
CISCO: '#1ba0d7', HUAWEI: '#cf0a2c', JUNIPER: '#84bd00',
PIOLINK: '#003087', SECUI: '#0066cc', RADWARE: '#00a3e0',
}
export default function NetworkConsole() {
const [devices, setDevices] = useState<NetworkDevice[]>([])
const [loading, setLoading] = useState(true)
const [backing, setBacking] = useState<number | null>(null)
const [diffDev, setDiffDev] = useState<number | null>(null)
const [diff, setDiff] = useState<DiffResult | null>(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 <div style={{ color: '#94a3b8', padding: 40 }}> ...</div>
return (
<div>
{/* 요약 */}
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
{[
{ 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 => (
<div key={c.label} style={{ background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 10, padding: '16px 22px', flex: 1 }}>
<div style={{ fontSize: 12, color: '#64748b' }}>{c.label}</div>
<div style={{ fontSize: 26, fontWeight: 700, color: c.color, marginTop: 4 }}>{c.value}</div>
</div>
))}
</div>
{msg && (
<div style={{ padding: '10px 16px', borderRadius: 8, marginBottom: 16,
background: msg.startsWith('✔') ? '#f0fdf4' : '#fef2f2',
color: msg.startsWith('✔') ? '#16a34a' : '#dc2626',
border: `1px solid ${msg.startsWith('✔') ? '#bbf7d0' : '#fecaca'}`,
fontSize: 13 }}>
{msg}
</div>
)}
{/* 검색 */}
<div style={{ marginBottom: 16 }}>
<input
value={filter} onChange={e => setFilter(e.target.value)}
placeholder="장비명 / 벤더 / 타입 검색..."
style={{ padding: '8px 14px', border: '1px solid #e2e8f0', borderRadius: 8,
fontSize: 13, width: 300, outline: 'none' }}
/>
</div>
{/* 장비 테이블 */}
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
overflow: 'hidden', marginBottom: 16 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{['장비명','타입','벤더/모델','위치','최근 백업','상태','액션'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left',
fontWeight: 600, color: '#475569', fontSize: 12,
borderBottom: '1px solid #e2e8f0' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(d => {
const days = daysSince(d.last_backup_at)
const backupStatus = !d.last_backup_at ? 'none'
: (days ?? 0) > 7 ? 'stale' : 'ok'
return (
<>
<tr key={d.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '10px 14px', fontWeight: 600, color: '#1e293b' }}>
{DEVICE_ICON[d.device_type] ?? '🔧'} {d.device_name}
</td>
<td style={{ padding: '10px 14px', color: '#475569' }}>{d.device_type}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ fontWeight: 700, color: VENDOR_COLOR[d.vendor] ?? '#475569' }}>
{d.vendor}
</span>
{d.model && <span style={{ color: '#94a3b8', fontSize: 11, marginLeft: 6 }}>
{d.model}
</span>}
</td>
<td style={{ padding: '10px 14px', color: '#64748b', fontSize: 12 }}>
{d.location ?? '-'}
</td>
<td style={{ padding: '10px 14px', fontSize: 12 }}>
{!d.last_backup_at
? <span style={{ color: '#ef4444', fontWeight: 600 }}></span>
: <span style={{ color: (days ?? 0) > 7 ? '#f59e0b' : '#64748b' }}>
{days}
</span>}
</td>
<td style={{ padding: '10px 14px' }}>
{backupStatus === 'ok' && <span style={{ color:'#22c55e', fontWeight:700 }}> </span>}
{backupStatus === 'stale' && <span style={{ color:'#f59e0b', fontWeight:700 }}> </span>}
{backupStatus === 'none' && <span style={{ color:'#ef4444', fontWeight:700 }}> </span>}
</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={() => doBackup(d.id, d.device_name)}
disabled={backing === d.id}
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6,
background: backing === d.id ? '#e2e8f0' : '#4f6ef7',
color: backing === d.id ? '#94a3b8' : '#fff',
border: 'none', cursor: backing === d.id ? 'not-allowed' : 'pointer' }}>
{backing === d.id ? '백업 중..' : '백업'}
</button>
<button onClick={() => showDiff(d.id)}
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6,
background: diffDev === d.id ? '#1e293b' : '#f1f5f9',
color: diffDev === d.id ? '#fff' : '#475569',
border: '1px solid #e2e8f0', cursor: 'pointer' }}>
Diff
</button>
</div>
</td>
</tr>
{diffDev === d.id && diff && (
<tr key={`diff-${d.id}`}>
<td colSpan={7} style={{ padding: '12px 20px', background: '#f8fafc',
borderBottom: '1px solid #e2e8f0' }}>
{diff.success ? (
diff.changed ? (
<div>
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8 }}>
{diff.added_lines} / {diff.removed_lines}
</div>
<pre style={{ fontSize: 11, background: '#1e293b', color: '#e2e8f0',
borderRadius: 6, padding: 12, overflow: 'auto', maxHeight: 200,
margin: 0 }}>
{(diff.diff ?? []).join('\n')}
</pre>
</div>
) : (
<span style={{ fontSize: 12, color: '#22c55e' }}> </span>
)
) : (
<span style={{ fontSize: 12, color: '#ef4444' }}>{diff.error}</span>
)}
</td>
</tr>
)}
</>
)
})}
{filtered.length === 0 && (
<tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>
{devices.length === 0 ? '등록된 네트워크 장비가 없습니다.' : '검색 결과 없음'}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}