From b06a35c512d0e2fe471bf848d7813740c8e8c8d2 Mon Sep 17 00:00:00 2001 From: DESKTOP-TKLFCPRython Date: Sun, 31 May 2026 09:53:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Manager=20DR=C2=B7=EB=84=A4?= =?UTF-8?q?=ED=8A=B8=EC=9B=8C=ED=81=AC=C2=B7CSAP=20=EA=B4=80=EC=A0=9C=20+?= =?UTF-8?q?=20Messenger=20DR=C2=B7=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- app/(tabs)/_layout.tsx | 84 ++++++++++++++++++ app/(tabs)/dr.tsx | 149 +++++++++++++++++++++++++++++++ app/(tabs)/network.tsx | 184 +++++++++++++++++++++++++++++++++++++++ hooks/useBiometric.ts | 25 ++++++ hooks/useOfflineCache.ts | 62 +++++++++++++ services/api.ts | 88 +++++++++++++++++++ 6 files changed, 592 insertions(+) create mode 100644 app/(tabs)/_layout.tsx create mode 100644 app/(tabs)/dr.tsx create mode 100644 app/(tabs)/network.tsx create mode 100644 hooks/useBiometric.ts create mode 100644 hooks/useOfflineCache.ts create mode 100644 services/api.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..cf21885a --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,84 @@ +import { Tabs } from 'expo-router' +import { View, Text, StyleSheet } from 'react-native' +import { COLORS } from '../../constants/Config' + +function TabIcon({ icon, label, focused }: { icon: string; label: string; focused: boolean }) { + return ( + + {icon} + {label} + + ) +} + +const tab = StyleSheet.create({ + wrap: { alignItems: 'center', paddingTop: 4 }, + active: {}, + icon: { fontSize: 22 }, + label: { fontSize: 10, color: COLORS.muted, marginTop: 2 }, + labelActive: { color: COLORS.accent, fontWeight: '600' }, +}) + +export default function TabLayout() { + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + ) +} diff --git a/app/(tabs)/dr.tsx b/app/(tabs)/dr.tsx new file mode 100644 index 00000000..68d97cee --- /dev/null +++ b/app/(tabs)/dr.tsx @@ -0,0 +1,149 @@ +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 = { + PASS: '#22c55e', FAIL: '#ef4444', PARTIAL: '#f59e0b', RUNNING: '#4f6ef7', +} + +export default function DRScreen() { + const [dash, setDash] = useState(null) + const [rto, setRto] = useState(null) + const [loading, setLoading] = useState(true) + const [refresh, setRefresh] = useState(false) + const [running, setRunning] = useState(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 ( + + + + ) + + const scenarios = rto?.scenarios ?? [] + + return ( + load(true)} tintColor={COLORS.accent} />}> + + {/* 요약 카드 */} + + {[ + { 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 => ( + + {c.value} + {c.label} + + ))} + + + {/* 시나리오 목록 */} + DR 시나리오 (RTO/RPO) + {scenarios.length === 0 + ? 등록된 DR 시나리오가 없습니다. + : scenarios.map(sc => ( + + + {sc.scenario_name} + {sc.last_test_result && ( + + {sc.last_test_result} + + )} + + + RTO 목표: {sc.rto_target ? `${sc.rto_target}분` : '-'} + 실적: {sc.rto_actual_avg != null ? `${sc.rto_actual_avg}분` : '기록없음'} + {sc.rto_met === true && ✔ 충족} + {sc.rto_met === false && ✘ 초과} + + handleTest(sc.scenario_id, sc.scenario_name)} + disabled={running === sc.scenario_id}> + + {running === sc.scenario_id ? '실행 중...' : '복구 테스트 실행'} + + + + ))} + + {/* 최근 테스트 이력 */} + 최근 테스트 이력 + {(dash?.recent_tests ?? []).slice(0, 5).map(t => ( + + #{t.test_id} {t.test_type} + + {t.status} + + + ))} + + + ) +} + +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 }, +}) diff --git a/app/(tabs)/network.tsx b/app/(tabs)/network.tsx new file mode 100644 index 00000000..d78e0d70 --- /dev/null +++ b/app/(tabs)/network.tsx @@ -0,0 +1,184 @@ +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 = { + SWITCH: '🔀', ROUTER: '🔗', FIREWALL: '🛡️', LOAD_BALANCER: '⚖️', +} +const VENDOR_COLOR: Record = { + 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([]) + const [loading, setLoading] = useState(true) + const [refresh, setRefresh] = useState(false) + const [backing, setBacking] = useState(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 ( + + ) + + return ( + load(true)} tintColor={COLORS.accent} />}> + + {/* 요약 */} + + {[ + { 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 => ( + + {c.value} + {c.label} + + ))} + + + {/* 검색 */} + + + + + {/* 장비 목록 */} + 네트워크 장비 ({filtered.length}개) + {filtered.length === 0 + ? {devices.length === 0 ? '등록된 장비가 없습니다.' : '검색 결과 없음'} + : filtered.map(d => { + const days = daysSince(d.last_backup_at) + const backupOk = days !== null && days <= 7 + + return ( + + + {DEVICE_ICON[d.device_type] ?? '🔧'} + + {d.device_name} + + + {d.vendor} + + {d.model ? ` · ${d.model}` : ''} + {d.location ? ` 📍${d.location}` : ''} + + + + + + + + {!d.last_backup_at + ? '⚠ 미백업' + : days! > 7 + ? `⚠ ${days}일 전 백업 (갱신 필요)` + : `✔ ${days}일 전 백업`} + + handleBackup(d.id, d.device_name)} + disabled={backing === d.id}> + + {backing === d.id ? '백업 중...' : '백업'} + + + + + ) + })} + + + ) +} + +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 }, +}) diff --git a/hooks/useBiometric.ts b/hooks/useBiometric.ts new file mode 100644 index 00000000..1d51ea65 --- /dev/null +++ b/hooks/useBiometric.ts @@ -0,0 +1,25 @@ +import * as LocalAuth from 'expo-local-authentication' + +export async function isBiometricAvailable(): Promise { + const hasHardware = await LocalAuth.hasHardwareAsync() + const isEnrolled = await LocalAuth.isEnrolledAsync() + return hasHardware && isEnrolled +} + +export async function authenticateWithBiometric( + promptMessage = 'GUARDiA 로그인' +): Promise<{ success: boolean; error?: string }> { + const available = await isBiometricAvailable() + if (!available) return { success: false, error: '생체인증을 사용할 수 없습니다.' } + + const result = await LocalAuth.authenticateAsync({ + promptMessage, + cancelLabel: '취소', + fallbackLabel: '비밀번호 사용', + disableDeviceFallback: false, + }) + + return result.success + ? { success: true } + : { success: false, error: result.error ?? '인증 실패' } +} diff --git a/hooks/useOfflineCache.ts b/hooks/useOfflineCache.ts new file mode 100644 index 00000000..71aa7e31 --- /dev/null +++ b/hooks/useOfflineCache.ts @@ -0,0 +1,62 @@ +import { useState, useEffect, useCallback } from 'react' +import * as SecureStore from 'expo-secure-store' +import NetInfo from '@react-native-community/netinfo' + +interface CacheOptions { + ttlMs?: number // 캐시 유효 시간 (ms), 기본 5분 +} + +export function useOfflineCache( + cacheKey: string, + fetcher: () => Promise<{ data: T }>, + options: CacheOptions = {} +) { + const { ttlMs = 5 * 60 * 1000 } = options + const [data, setData] = useState(null) + const [isOffline, setOffline] = useState(false) + const [loading, setLoading] = useState(true) + const [cachedAt, setCachedAt] = useState(null) + + useEffect(() => { + const unsub = NetInfo.addEventListener(state => { + setOffline(!(state.isConnected ?? true)) + }) + return () => unsub() + }, []) + + const load = useCallback(async () => { + setLoading(true) + try { + if (!isOffline) { + const result = await fetcher() + setData(result.data) + const now = new Date() + setCachedAt(now) + await SecureStore.setItemAsync(cacheKey, JSON.stringify({ + data: result.data, ts: now.toISOString(), + })) + } else { + const raw = await SecureStore.getItemAsync(cacheKey) + if (raw) { + const { data: cached, ts } = JSON.parse(raw) + const age = Date.now() - new Date(ts).getTime() + setData(cached) + setCachedAt(new Date(ts)) + if (age > ttlMs) { + console.warn(`[OfflineCache] ${cacheKey} 캐시 만료 (${Math.floor(age / 60000)}분 전)`) + } + } + } + } catch { + const raw = await SecureStore.getItemAsync(cacheKey) + if (raw) { + const { data: cached, ts } = JSON.parse(raw) + setData(cached); setCachedAt(new Date(ts)) + } + } finally { setLoading(false) } + }, [cacheKey, fetcher, isOffline, ttlMs]) + + useEffect(() => { load() }, [load]) + + return { data, isOffline, loading, cachedAt, reload: load } +} diff --git a/services/api.ts b/services/api.ts new file mode 100644 index 00000000..5e1910dd --- /dev/null +++ b/services/api.ts @@ -0,0 +1,88 @@ +import axios from 'axios' +import * as SecureStore from 'expo-secure-store' +import { API_BASE } from '../constants/Config' + +const client = axios.create({ + baseURL: API_BASE, + timeout: 15000, + // 자체서명 인증서 허용 (개발/테스트) +}) + +client.interceptors.request.use(async (cfg) => { + const token = await SecureStore.getItemAsync('grd_token') + if (token) cfg.headers.Authorization = `Bearer ${token}` + return cfg +}) + +client.interceptors.response.use( + (r) => r, + async (err) => { + if (err.response?.status === 401) { + await SecureStore.deleteItemAsync('grd_token') + await SecureStore.deleteItemAsync('grd_user') + } + return Promise.reject(err) + } +) + +/* ── 인증 ── */ +export const login = (username: string, password: string) => + client.post('/api/auth/login', { username, password }) + +export const getMe = () => + client.get('/api/auth/me') + +/* ── 대시보드 ── */ +export const getDashboard = () => + client.get('/api/dashboard') + +/* ── SR ── */ +export const getSRList = (page = 0, size = 20) => + client.get(`/api/tasks?page=${page}&size=${size}`) + +export const getSRDetail = (id: number) => + client.get(`/api/tasks/${id}`) + +export const createSR = (data: { title: string; description: string; priority: string; sr_type: string }) => + client.post('/api/tasks', data) + +export const updateSRStatus = (id: number, status: string) => + client.patch(`/api/tasks/${id}/status`, { status }) + +/* ── AI 챗봇 ── */ +export const sendAIMessage = (message: string) => + client.post('/api/chatbot/message', { message }) + +/* ── 라이선스 ── */ +export const getLicenseStatus = () => + client.get('/api/license/status') + +/* ── 알림 ── */ +export const getNotifications = () => + client.get('/api/notifications?size=30') + +export const markNotificationRead = (id: number) => + client.patch(`/api/notifications/${id}/read`) + +/* ── DR 자동화 ── */ +export const getDRDashboard = () => client.get('/api/dr/dashboard') +export const getDRRtoRpo = () => client.get('/api/dr/rto-rpo') +export const getDRScenarios = () => client.get('/api/dr/scenarios') +export const runDRTest = (scenarioId: number) => + client.post('/api/dr/test', { scenario_id: scenarioId, test_type: 'RECOVERY' }) + +/* ── 네트워크 장비 ── */ +export const getNetworkDevices = (instId?: number) => + client.get('/api/network/devices', { params: instId ? { inst_id: instId } : {} }) +export const getNetworkTopology = () => client.get('/api/network/topology') +export const backupNetworkDevice = (id: number) => + client.post(`/api/network/devices/${id}/backup`) +export const getNetworkDiff = (id: number) => + client.get(`/api/network/devices/${id}/diff`) + +/* ── CSAP 점검 ── */ +export const getCSAPDashboard = () => client.get('/api/compliance/csap/dashboard') +export const getCSAPItems = () => client.get('/api/compliance/csap/items') +export const getCSAPResults = () => client.get('/api/compliance/csap/results') + +export default client