guardia-messenger/app/(tabs)/network.tsx
DESKTOP-TKLFCPRython b06a35c512 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>
2026-05-31 09:53:17 +09:00

185 lines
7.7 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react'
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, RefreshControl, Alert, ActivityIndicator, TextInput,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { getNetworkDevices, backupNetworkDevice } from '../../services/api'
interface NetworkDevice {
id: number; device_name: string; device_type: string
vendor: string; model?: string; location?: string
is_active: boolean; last_backup_at?: string | null
}
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',
}
function daysSince(iso?: string | null): number | null {
if (!iso) return null
return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
}
export default function NetworkScreen() {
const [devices, setDevices] = useState<NetworkDevice[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [backing, setBacking] = useState<number | null>(null)
const [search, setSearch] = useState('')
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : setLoading(true)
try {
const r = await getNetworkDevices()
setDevices(r.data)
} catch { /* 무시 */ }
finally { setLoading(false); setRefresh(false) }
}, [])
useEffect(() => { load() }, [load])
const handleBackup = (id: number, name: string) => {
Alert.alert('설정 백업', `"${name}" 설정을 백업하시겠습니까?`, [
{ text: '취소', style: 'cancel' },
{ text: '백업', onPress: async () => {
setBacking(id)
try {
const r = await backupNetworkDevice(id)
const changed = r.data.changed_lines > 0 ? ` (변경 ${r.data.changed_lines}줄 감지!)` : ''
Alert.alert('완료', `백업 성공${changed}`)
load()
} catch (e: any) {
Alert.alert('오류', e.response?.data?.detail ?? '백업 실패')
} finally { setBacking(null) }
}},
])
}
const filtered = devices.filter(d =>
!search || [d.device_name, d.vendor, d.device_type, d.location ?? '']
.some(v => v.toLowerCase().includes(search.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 (
<View style={s.center}><ActivityIndicator color={COLORS.accent} size="large" /></View>
)
return (
<ScrollView style={s.container}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}>
{/* 요약 */}
<View style={s.row}>
{[
{ label: '전체', value: devices.length, color: COLORS.text },
{ label: '미백업', value: noBackup, color: '#ef4444' },
{ label: '7일초과', value: stale, color: '#f59e0b' },
{ label: '정상', value: devices.length - noBackup - stale, color: '#22c55e' },
].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>
{/* 검색 */}
<View style={s.searchWrap}>
<TextInput
style={s.searchInput}
value={search}
onChangeText={setSearch}
placeholder="장비명 / 벤더 검색..."
placeholderTextColor={COLORS.muted}
/>
</View>
{/* 장비 목록 */}
<Text style={s.sectionTitle}> ({filtered.length})</Text>
{filtered.length === 0
? <Text style={s.empty}>{devices.length === 0 ? '등록된 장비가 없습니다.' : '검색 결과 없음'}</Text>
: filtered.map(d => {
const days = daysSince(d.last_backup_at)
const backupOk = days !== null && days <= 7
return (
<View key={d.id} style={s.card}>
<View style={s.cardTop}>
<Text style={s.icon}>{DEVICE_ICON[d.device_type] ?? '🔧'}</Text>
<View style={{ flex: 1 }}>
<Text style={s.deviceName}>{d.device_name}</Text>
<Text style={s.vendorText} numberOfLines={1}>
<Text style={{ color: VENDOR_COLOR[d.vendor] ?? COLORS.muted, fontWeight: '700' }}>
{d.vendor}
</Text>
{d.model ? ` · ${d.model}` : ''}
{d.location ? ` 📍${d.location}` : ''}
</Text>
</View>
<View style={[s.statusDot, { backgroundColor: backupOk ? '#22c55e' : (days !== null ? '#f59e0b' : '#ef4444') }]} />
</View>
<View style={s.backupRow}>
<Text style={s.backupText}>
{!d.last_backup_at
? '⚠ 미백업'
: days! > 7
? `${days}일 전 백업 (갱신 필요)`
: `${days}일 전 백업`}
</Text>
<TouchableOpacity
style={[s.backupBtn, backing === d.id && s.backupBtnDisabled]}
onPress={() => handleBackup(d.id, d.device_name)}
disabled={backing === d.id}>
<Text style={s.backupBtnText}>
{backing === d.id ? '백업 중...' : '백업'}
</Text>
</TouchableOpacity>
</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: 12,
alignItems: 'center', shadowColor: '#000', shadowOpacity: 0.04,
shadowRadius: 4, elevation: 2 },
statNum: { fontSize: 22, fontWeight: '700' },
statLabel: { fontSize: 10, color: '#94a3b8', marginTop: 3 },
searchWrap: { paddingHorizontal: 16, marginBottom: 8 },
searchInput: { backgroundColor: '#fff', borderRadius: 8, padding: 10,
fontSize: 13, color: COLORS.text,
borderWidth: 1, borderColor: '#e2e8f0' },
sectionTitle: { fontSize: 13, fontWeight: '700', color: '#1e293b',
paddingHorizontal: 16, marginBottom: 8 },
card: { backgroundColor: '#fff', borderRadius: 10, padding: 14,
marginHorizontal: 16, marginBottom: 10,
shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 4, elevation: 2 },
cardTop: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 },
icon: { fontSize: 24 },
deviceName: { fontSize: 14, fontWeight: '600', color: '#1e293b' },
vendorText: { fontSize: 12, color: '#64748b', marginTop: 2 },
statusDot: { width: 10, height: 10, borderRadius: 5 },
backupRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
backupText: { fontSize: 12, color: '#64748b', flex: 1 },
backupBtn: { backgroundColor: COLORS.accent, borderRadius: 6,
paddingHorizontal: 14, paddingVertical: 6 },
backupBtnDisabled: { backgroundColor: '#e2e8f0' },
backupBtnText: { color: '#fff', fontSize: 12, fontWeight: '600' },
empty: { color: '#94a3b8', textAlign: 'center', padding: 30, fontSize: 13 },
})