## 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>
150 lines
7.0 KiB
TypeScript
150 lines
7.0 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import {
|
|
View, Text, ScrollView, TouchableOpacity,
|
|
StyleSheet, RefreshControl, Alert, ActivityIndicator,
|
|
} from 'react-native'
|
|
import { COLORS } from '../../constants/Config'
|
|
import { getDRDashboard, getDRRtoRpo, runDRTest } from '../../services/api'
|
|
|
|
interface DRTest { test_id: number; test_type: string; status: string; started_at: string }
|
|
interface DRDash { total_scenarios: number; pass_count: number; fail_count: number; untested_count: number; recent_tests: DRTest[] }
|
|
interface RtoScen { scenario_id: number; scenario_name: string; rto_target: number | null; rto_actual_avg: number | null; rto_met: boolean | null; last_test_result: string | null }
|
|
interface RtoRpo { scenarios: RtoScen[] }
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
PASS: '#22c55e', FAIL: '#ef4444', PARTIAL: '#f59e0b', RUNNING: '#4f6ef7',
|
|
}
|
|
|
|
export default function DRScreen() {
|
|
const [dash, setDash] = useState<DRDash | null>(null)
|
|
const [rto, setRto] = useState<RtoRpo | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [refresh, setRefresh] = useState(false)
|
|
const [running, setRunning] = useState<number | null>(null)
|
|
|
|
const load = useCallback(async (isRefresh = false) => {
|
|
isRefresh ? setRefresh(true) : setLoading(true)
|
|
try {
|
|
const [d, r] = await Promise.all([getDRDashboard(), getDRRtoRpo()])
|
|
setDash(d.data); setRto(r.data)
|
|
} catch { /* 네트워크 오류 무시 */ }
|
|
finally { setLoading(false); setRefresh(false) }
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const handleTest = (scenarioId: number, name: string) => {
|
|
Alert.alert('복구 테스트', `"${name}" 복구 테스트를 실행하시겠습니까?`, [
|
|
{ text: '취소', style: 'cancel' },
|
|
{ text: '실행', onPress: async () => {
|
|
setRunning(scenarioId)
|
|
try {
|
|
const r = await runDRTest(scenarioId)
|
|
Alert.alert('완료', `상태: ${r.data.status}\nRTO 실적: ${r.data.rto_actual_minutes ?? '-'}분`)
|
|
load()
|
|
} catch (e: any) {
|
|
Alert.alert('오류', e.response?.data?.detail ?? '테스트 실행 실패')
|
|
} finally { setRunning(null) }
|
|
}},
|
|
])
|
|
}
|
|
|
|
if (loading) return (
|
|
<View style={s.center}>
|
|
<ActivityIndicator color={COLORS.accent} size="large" />
|
|
</View>
|
|
)
|
|
|
|
const scenarios = rto?.scenarios ?? []
|
|
|
|
return (
|
|
<ScrollView style={s.container}
|
|
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}>
|
|
|
|
{/* 요약 카드 */}
|
|
<View style={s.row}>
|
|
{[
|
|
{ label: '전체', value: dash?.total_scenarios ?? 0, color: COLORS.text },
|
|
{ label: 'PASS', value: dash?.pass_count ?? 0, color: '#22c55e' },
|
|
{ label: 'FAIL', value: dash?.fail_count ?? 0, color: '#ef4444' },
|
|
{ label: '미테스트', value: dash?.untested_count ?? 0, color: '#f59e0b' },
|
|
].map(c => (
|
|
<View key={c.label} style={s.statCard}>
|
|
<Text style={[s.statNum, { color: c.color }]}>{c.value}</Text>
|
|
<Text style={s.statLabel}>{c.label}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
{/* 시나리오 목록 */}
|
|
<Text style={s.sectionTitle}>DR 시나리오 (RTO/RPO)</Text>
|
|
{scenarios.length === 0
|
|
? <Text style={s.empty}>등록된 DR 시나리오가 없습니다.</Text>
|
|
: scenarios.map(sc => (
|
|
<View key={sc.scenario_id} style={s.card}>
|
|
<View style={s.cardRow}>
|
|
<Text style={s.cardName}>{sc.scenario_name}</Text>
|
|
{sc.last_test_result && (
|
|
<View style={[s.badge, { backgroundColor: STATUS_COLOR[sc.last_test_result] ?? '#94a3b8' }]}>
|
|
<Text style={s.badgeText}>{sc.last_test_result}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<View style={s.cardMeta}>
|
|
<Text style={s.metaText}>RTO 목표: {sc.rto_target ? `${sc.rto_target}분` : '-'}</Text>
|
|
<Text style={s.metaText}>실적: {sc.rto_actual_avg != null ? `${sc.rto_actual_avg}분` : '기록없음'}</Text>
|
|
{sc.rto_met === true && <Text style={[s.metaText, { color: '#22c55e' }]}>✔ 충족</Text>}
|
|
{sc.rto_met === false && <Text style={[s.metaText, { color: '#ef4444' }]}>✘ 초과</Text>}
|
|
</View>
|
|
<TouchableOpacity
|
|
style={[s.btn, running === sc.scenario_id && s.btnDisabled]}
|
|
onPress={() => handleTest(sc.scenario_id, sc.scenario_name)}
|
|
disabled={running === sc.scenario_id}>
|
|
<Text style={s.btnText}>
|
|
{running === sc.scenario_id ? '실행 중...' : '복구 테스트 실행'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
))}
|
|
|
|
{/* 최근 테스트 이력 */}
|
|
<Text style={s.sectionTitle}>최근 테스트 이력</Text>
|
|
{(dash?.recent_tests ?? []).slice(0, 5).map(t => (
|
|
<View key={t.test_id} style={[s.card, { flexDirection: 'row', alignItems: 'center', paddingVertical: 10 }]}>
|
|
<Text style={[s.metaText, { flex: 1 }]}>#{t.test_id} {t.test_type}</Text>
|
|
<View style={[s.badge, { backgroundColor: STATUS_COLOR[t.status] ?? '#94a3b8' }]}>
|
|
<Text style={s.badgeText}>{t.status}</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
<View style={{ height: 40 }} />
|
|
</ScrollView>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: '#f5f7fa' },
|
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
row: { flexDirection: 'row', gap: 10, padding: 16 },
|
|
statCard: { flex: 1, backgroundColor: '#fff', borderRadius: 10, padding: 14,
|
|
alignItems: 'center', shadowColor: '#000', shadowOpacity: 0.04,
|
|
shadowRadius: 4, elevation: 2 },
|
|
statNum: { fontSize: 24, fontWeight: '700' },
|
|
statLabel: { fontSize: 11, color: '#94a3b8', marginTop: 4 },
|
|
sectionTitle: { fontSize: 13, fontWeight: '700', color: '#1e293b',
|
|
paddingHorizontal: 16, marginBottom: 8, marginTop: 4 },
|
|
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14,
|
|
marginHorizontal: 16, marginBottom: 10,
|
|
shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 4, elevation: 2 },
|
|
cardRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 },
|
|
cardName: { fontSize: 14, fontWeight: '600', color: '#1e293b', flex: 1 },
|
|
cardMeta: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
|
metaText: { fontSize: 12, color: '#64748b' },
|
|
badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10 },
|
|
badgeText: { fontSize: 10, fontWeight: '700', color: '#fff' },
|
|
btn: { backgroundColor: COLORS.accent, borderRadius: 8, padding: 10, alignItems: 'center' },
|
|
btnDisabled: { backgroundColor: '#e2e8f0' },
|
|
btnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
|
empty: { color: '#94a3b8', textAlign: 'center', padding: 30, fontSize: 13 },
|
|
})
|