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