## 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>
208 lines
8.8 KiB
TypeScript
208 lines
8.8 KiB
TypeScript
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>
|
|
)
|
|
}
|