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