## 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>
185 lines
7.7 KiB
TypeScript
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 },
|
|
})
|