diff --git a/app/app/(tabs)/_layout.tsx b/app/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..cf21885a --- /dev/null +++ b/app/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/app/(tabs)/dr.tsx b/app/app/(tabs)/dr.tsx new file mode 100644 index 00000000..68d97cee --- /dev/null +++ b/app/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/app/(tabs)/network.tsx b/app/app/(tabs)/network.tsx new file mode 100644 index 00000000..d78e0d70 --- /dev/null +++ b/app/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/app/hooks/useBiometric.ts b/app/hooks/useBiometric.ts new file mode 100644 index 00000000..1d51ea65 --- /dev/null +++ b/app/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/app/hooks/useOfflineCache.ts b/app/hooks/useOfflineCache.ts new file mode 100644 index 00000000..71aa7e31 --- /dev/null +++ b/app/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/app/services/api.ts b/app/services/api.ts new file mode 100644 index 00000000..5e1910dd --- /dev/null +++ b/app/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 diff --git a/manager/frontend/src/App.tsx b/manager/frontend/src/App.tsx new file mode 100644 index 00000000..e2e09dd6 --- /dev/null +++ b/manager/frontend/src/App.tsx @@ -0,0 +1,67 @@ +import { lazy, Suspense } from 'react' +import { Routes, Route, Navigate } from 'react-router-dom' +import { AppLayout } from './components/layout/AppLayout' +import { ProtectedRoute } from './components/common/ProtectedRoute' + +const Login = lazy(() => import('./pages/Login')) +const Dashboard = lazy(() => import('./pages/Dashboard')) +const Servers = lazy(() => import('./pages/Servers')) +const CMDB = lazy(() => import('./pages/CMDB')) +const Deployments = lazy(() => import('./pages/Deployments')) +const Repos = lazy(() => import('./pages/Repos')) +const Users = lazy(() => import('./pages/Users')) +const Institutions = lazy(() => import('./pages/Institutions')) +const ApiKeys = lazy(() => import('./pages/ApiKeys')) +const AuditLog = lazy(() => import('./pages/AuditLog')) +const LLMManager = lazy(() => import('./pages/LLMManager')) +const ConfigEnv = lazy(() => import('./pages/ConfigEnv')) +const ConfigNginx = lazy(() => import('./pages/ConfigNginx')) +const Notifications = lazy(() => import('./pages/Notifications')) +const Licenses = lazy(() => import('./pages/Licenses')) +const ExportImport = lazy(() => import('./pages/ExportImport')) +const DrConsole = lazy(() => import('./pages/DrConsole')) +const NetworkConsole = lazy(() => import('./pages/NetworkConsole')) +const CsapConsole = lazy(() => import('./pages/CsapConsole')) + +function Loading() { + return ( +
+ + λ‘œλ”© 쀑... +
+ ) +} + +export default function App() { + return ( + }> + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ) +} diff --git a/manager/frontend/src/components/layout/AppLayout.tsx b/manager/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 00000000..4fc07c67 --- /dev/null +++ b/manager/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,52 @@ +import { Outlet, useLocation } from 'react-router-dom' +import { GNB } from './GNB' +import { Sidebar } from './Sidebar' + +const PAGE_TITLES: Record = { + '/': '톡합 운영 λŒ€μ‹œλ³΄λ“œ', + '/servers': 'μ„œλ²„ λͺ©λ‘', + '/cmdb': 'CMDB ν˜„ν™©', + '/deployments': '배포 이λ ₯', + '/repos': 'μ €μž₯μ†Œ λͺ©λ‘', + '/users': 'μ‚¬μš©μž 관리', + '/institutions': 'κΈ°κ΄€ 관리', + '/api-keys': 'API Key 관리', + '/audit': '감사 둜그', + '/llm': 'AI / LLM 관리', + '/config/env': 'ν™˜κ²½λ³€μˆ˜ μ„€μ •', + '/config/nginx': 'Nginx 관리', + '/notifications': 'μ•Œλ¦Ό / 리포트', + '/licenses': 'λΌμ΄μ„ μŠ€ 관리', + '/export-import': '폐쇄망 데이터 연동 (Export / Import)', + '/dr': 'DR μž¬ν•΄λ³΅κ΅¬ κ΄€μ œ', + '/network': 'λ„€νŠΈμ›Œν¬ μž₯λΉ„ κ΄€μ œ', + '/csap': 'CSAP λ³΄μ•ˆ 점검', +} + +export function AppLayout() { + const { pathname } = useLocation() + const title = PAGE_TITLES[pathname] ?? 'GUARDiA Manager' + + return ( +
+ +
+ +
+ {/* νŽ˜μ΄μ§€ 타이틀 λ°” */} +
+

{title}

+
+ {/* μ½˜ν…μΈ  */} +
+ +
+
+
+
+ ) +} diff --git a/manager/frontend/src/components/layout/Sidebar.tsx b/manager/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 00000000..92cd6618 --- /dev/null +++ b/manager/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,95 @@ +import { NavLink } from 'react-router-dom' +import { useState } from 'react' + +interface NavItem { label: string; path?: string; icon: string; children?: NavItem[] } + +const NAV: NavItem[] = [ + { label: 'λŒ€μ‹œλ³΄λ“œ', icon: 'πŸ“Š', path: '/' }, + { label: '인프라 관리', icon: 'πŸ–₯️', children: [ + { label: 'μ„œλ²„ λͺ©λ‘', icon: '', path: '/servers' }, + { label: 'CMDB ν˜„ν™©', icon: '', path: '/cmdb' }, + ]}, + { label: '배포/CI-CD', icon: 'πŸš€', children: [ + { label: '배포 이λ ₯', icon: '', path: '/deployments' }, + { label: 'μ €μž₯μ†Œ λͺ©λ‘',icon: '', path: '/repos' }, + ]}, + { label: 'μ‚¬μš©μž/ν…Œλ„ŒνŠΈ',icon: 'πŸ‘₯', children: [ + { label: 'μ‚¬μš©μž 관리',icon: '', path: '/users' }, + { label: 'κΈ°κ΄€ 관리', icon: '', path: '/institutions' }, + ]}, + { label: 'λ³΄μ•ˆ', icon: 'πŸ”’', children: [ + { label: 'API Key', icon: '', path: '/api-keys' }, + { label: '감사 둜그', icon: '', path: '/audit' }, + ]}, + { label: 'AI / LLM', icon: 'πŸ€–', path: '/llm' }, + { label: 'μ‹œμŠ€ν…œ μ„€μ •', icon: 'βš™οΈ', children: [ + { label: 'ν™˜κ²½λ³€μˆ˜', icon: '', path: '/config/env' }, + { label: 'Nginx 관리', icon: '', path: '/config/nginx' }, + ]}, + { label: 'μ•Œλ¦Ό/리포트', icon: 'πŸ””', path: '/notifications' }, + { label: 'λΌμ΄μ„ μŠ€ 관리',icon: 'πŸͺͺ', path: '/licenses' }, + { label: '데이터 연동', icon: 'πŸ”„', path: '/export-import' }, + { label: '운영 κ΄€μ œ', icon: 'πŸ›°οΈ', children: [ + { label: 'DR μž¬ν•΄λ³΅κ΅¬', icon: '', path: '/dr' }, + { label: 'λ„€νŠΈμ›Œν¬ μž₯λΉ„', icon: '', path: '/network' }, + { label: 'CSAP 점검', icon: '', path: '/csap' }, + ]}, +] + +function NavGroup({ item }: { item: NavItem }) { + const [open, setOpen] = useState(true) + if (!item.children) { + return ( + ({ + display: 'flex', alignItems: 'center', gap: 8, + padding: '8px 16px', fontSize: 13, color: isActive ? '#1a3a6b' : '#475569', + background: isActive ? '#dde4f5' : 'transparent', + borderLeft: isActive ? '3px solid #4f6ef7' : '3px solid transparent', + transition: 'all .15s', + })}> + {item.icon} + {item.label} + + ) + } + return ( +
+ + {open && item.children.map(c => ( + ({ + display: 'flex', alignItems: 'center', + padding: '7px 16px 7px 42px', fontSize: 12.5, + color: isActive ? '#1a3a6b' : '#64748b', + background: isActive ? '#dde4f5' : 'transparent', + borderLeft: isActive ? '3px solid #4f6ef7' : '3px solid transparent', + transition: 'all .15s', + })}> + {c.label} + + ))} +
+ ) +} + +export function Sidebar() { + return ( + + ) +} diff --git a/manager/frontend/src/pages/CsapConsole.tsx b/manager/frontend/src/pages/CsapConsole.tsx new file mode 100644 index 00000000..8d564463 --- /dev/null +++ b/manager/frontend/src/pages/CsapConsole.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from 'react' +import { guardiaApi } from '../api/clients' + +interface CsapSite { + inst_id: number; scan_id: string; compliance_rate: number + grade: string; pass_count: number; total: number + scanned_at: string | null +} +interface CsapItem { + id: string; cat: string; sev: string; auto: boolean; name: string +} +interface ScanResult { + scan_id: string; inst_id: number + total_items: number; pass: number; fail: number + partial: number; manual_required: number + compliance_rate: number; grade: string + critical_findings: string[] +} + +const GRADE_COLOR: Record = { + A: '#22c55e', B: '#4f6ef7', C: '#f59e0b', D: '#ef4444', +} +const SEV_COLOR: Record = { + HIGH: '#ef4444', MEDIUM: '#f59e0b', LOW: '#22c55e', +} + +export default function CsapConsole() { + const [sites, setSites] = useState([]) + const [items, setItems] = useState([]) + const [selected, setSelected] = useState(null) + const [scanning, setScanning] = useState(null) + const [loading, setLoading] = useState(true) + const [msg, setMsg] = useState('') + + const load = () => { + Promise.all([ + guardiaApi.get('/api/compliance/csap/dashboard'), + guardiaApi.get('/api/compliance/csap/items'), + ]).then(([d, i]) => { + setSites(d.data.institutions ?? []) + setItems(i.data.items ?? []) + }).finally(() => setLoading(false)) + } + + useEffect(() => { load() }, []) + + const runScan = async (instId: number) => { + if (!confirm(`κΈ°κ΄€ #${instId} CSAP 전체 점검을 μ‹€ν–‰ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`)) return + setScanning(instId); setMsg('') + try { + const r = await guardiaApi.post('/api/compliance/csap/scan', { inst_id: instId }) + const res: ScanResult = r.data + setMsg(`βœ” 점검 μ™„λ£Œ β€” μ€€μˆ˜μœ¨ ${res.compliance_rate}% (${res.grade}λ“±κΈ‰)`) + load() + } catch (e: any) { + setMsg(`✘ 점검 μ‹€νŒ¨: ${e.response?.data?.detail ?? e.message}`) + } finally { setScanning(null) } + } + + const downloadExcel = (scanId: string) => { + guardiaApi.get(`/api/compliance/csap/report/excel?scan_id=${scanId}`, + { responseType: 'blob' } + ).then(r => { + const url = URL.createObjectURL(r.data) + const a = document.createElement('a') + a.href = url; a.download = `CSAP_${scanId}.xlsx`; a.click() + URL.revokeObjectURL(url) + }) + } + + const catSummary = items.reduce>( + (acc, i) => { + if (!acc[i.cat]) acc[i.cat] = { total: 0, auto: 0 } + acc[i.cat].total++ + if (i.auto) acc[i.cat].auto++ + return acc + }, {} + ) + + if (loading) return
λ‘œλ”© 쀑...
+ + return ( +
+ {/* 점검 ν•­λͺ© μš”μ•½ */} +
+ {Object.entries(catSummary).map(([cat, s]) => ( +
+
{cat} λ³΄μ•ˆ
+
+ {s.total}개 +
+
+ μžλ™ {s.auto}개 / μˆ˜λ™ {s.total - s.auto}개 +
+
+ ))} +
+
전체 ν•­λͺ©
+
+ {items.length}개 +
+
+ μžλ™ {items.filter(i => i.auto).length}개 / μˆ˜λ™ {items.filter(i => !i.auto).length}개 +
+
+
+ + {msg && ( +
+ {msg} +
+ )} + + {/* 기관별 μ€€μˆ˜μœ¨ ν…Œμ΄λΈ” */} +
+
+ 기관별 CSAP μ€€μˆ˜μœ¨ +
+ + + + {['κΈ°κ΄€ ID','μ€€μˆ˜μœ¨','λ“±κΈ‰','톡과/전체','λ§ˆμ§€λ§‰ 점검','μ•‘μ…˜'].map(h => ( + + ))} + + + + {sites.map(s => ( + setSelected(selected?.inst_id === s.inst_id ? null : s)}> + + + + + + + + ))} + {sites.length === 0 && ( + + )} + +
{h} +
κΈ°κ΄€ #{s.inst_id} +
+
+
+
+ + {s.compliance_rate}% + +
+
+ + {s.grade}λ“±κΈ‰ + + + {s.pass_count}/{s.total} + + {s.scanned_at ? new Date(s.scanned_at).toLocaleDateString('ko-KR') : '미점검'} + +
e.stopPropagation()}> + + {s.scan_id && ( + + )} +
+
+ 점검 이λ ₯이 μ—†μŠ΅λ‹ˆλ‹€. 기관을 선택해 점검을 μ‹€ν–‰ν•˜μ„Έμš”. +
+
+ + {/* 점검 ν•­λͺ© λͺ©λ‘ */} +
+
+ CSAP 점검 ν•­λͺ© λͺ©λ‘ ({items.length}개) +
+
+ + + + {['ν•­λͺ©ID','μΉ΄ν…Œκ³ λ¦¬','ν•­λͺ©λͺ…','심각도','점검방식'].map(h => ( + + ))} + + + + {items.map(i => ( + + + + + + + + ))} + +
{h} +
{i.id}{i.cat}{i.name} + + {i.sev} + + + + {i.auto ? '⚑ μžλ™' : 'πŸ“‹ μˆ˜λ™'} + +
+
+
+
+ ) +} diff --git a/manager/frontend/src/pages/DrConsole.tsx b/manager/frontend/src/pages/DrConsole.tsx new file mode 100644 index 00000000..9510bf21 --- /dev/null +++ b/manager/frontend/src/pages/DrConsole.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react' +import { guardiaApi } from '../api/clients' + +interface DRScenario { + id: number; name: string; scenario_type: string + rto_minutes: number | null; rto_actual_avg?: number | null + last_test_at: string | null; last_test_result: string | null + rto_met?: boolean | null +} +interface DRTest { + test_id: number; scenario_id: number; test_type: string + status: string; started_at: string +} +interface DRDashboard { + total_scenarios: number; pass_count: number + fail_count: number; untested_count: number + recent_tests: DRTest[] +} +interface RtoRpo { scenarios: DRScenario[] } + +const STATUS_COLOR: Record = { + PASS: '#22c55e', FAIL: '#ef4444', PARTIAL: '#f59e0b', + RUNNING: '#4f6ef7', UNTESTED: '#94a3b8', +} +const TYPE_LABEL: Record = { + SERVER_FAILURE: 'μ„œλ²„ μž₯μ• ', SITE_FAILURE: 'μ‚¬μ΄νŠΈ μž₯μ• ', DATA_CORRUPTION: '데이터 손상', +} + +function Badge({ status }: { status: string }) { + const color = STATUS_COLOR[status] ?? '#94a3b8' + return ( + + {status} + + ) +} + +function Card({ title, value, sub, color }: { title: string; value: number; sub?: string; color?: string }) { + return ( +
+
{title}
+
{value}
+ {sub &&
{sub}
} +
+ ) +} + +export default function DrConsole() { + const [dashboard, setDashboard] = useState(null) + const [rtoRpo, setRtoRpo] = useState(null) + const [loading, setLoading] = useState(true) + const [running, setRunning] = useState(null) + const [msg, setMsg] = useState('') + + const load = () => { + setLoading(true) + Promise.all([ + guardiaApi.get('/api/dr/dashboard'), + guardiaApi.get('/api/dr/rto-rpo'), + ]).then(([d, r]) => { + setDashboard(d.data) + setRtoRpo(r.data) + }).finally(() => setLoading(false)) + } + + useEffect(() => { load() }, []) + + const runTest = async (scenarioId: number) => { + if (!confirm('볡ꡬ ν…ŒμŠ€νŠΈλ₯Ό μ‹€ν–‰ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?')) return + setRunning(scenarioId) + setMsg('') + try { + const r = await guardiaApi.post('/api/dr/test', { + scenario_id: scenarioId, test_type: 'RECOVERY', + }) + setMsg(`ν…ŒμŠ€νŠΈ ${r.data.status} β€” RTO 싀적: ${r.data.rto_actual_minutes ?? '-'}λΆ„`) + load() + } catch (e: any) { + setMsg('ν…ŒμŠ€νŠΈ μ‹€ν–‰ μ‹€νŒ¨: ' + (e.response?.data?.detail ?? e.message)) + } finally { setRunning(null) } + } + + if (loading) return
λ‘œλ”© 쀑...
+ + const scenarios = rtoRpo?.scenarios ?? [] + + return ( +
+ {/* μš”μ•½ μΉ΄λ“œ */} +
+ + + + +
+ + {msg && ( +
+ {msg} +
+ )} + + {/* μ‹œλ‚˜λ¦¬μ˜€ ν…Œμ΄λΈ” */} +
+
+ DR μ‹œλ‚˜λ¦¬μ˜€ λͺ©λ‘ (RTO/RPO ν˜„ν™©) +
+ + + + {['μ‹œλ‚˜λ¦¬μ˜€λͺ…','μœ ν˜•','RTO λͺ©ν‘œ','RTO 싀적','μΆ©μ‘± μ—¬λΆ€','λ§ˆμ§€λ§‰ ν…ŒμŠ€νŠΈ','μƒνƒœ','μ•‘μ…˜'].map(h => ( + + ))} + + + + {scenarios.map(sc => ( + + + + + + + + + + + ))} + {scenarios.length === 0 && ( + + )} + +
+ {h} +
{sc.name} + {TYPE_LABEL[sc.scenario_type] ?? sc.scenario_type} + + {sc.rto_minutes ? `${sc.rto_minutes}λΆ„` : '-'} + + {sc.rto_actual_avg != null ? `${sc.rto_actual_avg}λΆ„` : '기둝 μ—†μŒ'} + + {sc.rto_met === true && βœ” μΆ©μ‘±} + {sc.rto_met === false && ✘ 초과} + {sc.rto_met == null && -} + + {sc.last_test_at ? new Date(sc.last_test_at).toLocaleDateString('ko-KR') : '-'} + + + + +
+ λ“±λ‘λœ DR μ‹œλ‚˜λ¦¬μ˜€κ°€ μ—†μŠ΅λ‹ˆλ‹€. +
+
+ + {/* 졜근 ν…ŒμŠ€νŠΈ 이λ ₯ */} +
+
+ 졜근 ν…ŒμŠ€νŠΈ 이λ ₯ +
+ + + + {['ν…ŒμŠ€νŠΈ ID','μœ ν˜•','μƒνƒœ','μ‹œμž‘μΌμ‹œ'].map(h => ( + + ))} + + + + {(dashboard?.recent_tests ?? []).map(t => ( + + + + + + + ))} + +
+ {h} +
#{t.test_id}{t.test_type} + {new Date(t.started_at).toLocaleString('ko-KR')} +
+
+
+ ) +} diff --git a/manager/frontend/src/pages/NetworkConsole.tsx b/manager/frontend/src/pages/NetworkConsole.tsx new file mode 100644 index 00000000..d5ea8bba --- /dev/null +++ b/manager/frontend/src/pages/NetworkConsole.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState } from 'react' +import { guardiaApi } from '../api/clients' + +interface NetworkDevice { + id: number; device_name: string; device_type: string + vendor: string; model?: string; os_type: string + location?: string; inst_id?: number; is_active: boolean + last_backup_at?: string | null +} +interface BackupResult { + success: boolean; device_name: string; backup_id?: number + config_hash?: string; changed_lines?: number + backed_up_at?: string; error?: string +} +interface DiffResult { + success: boolean; changed: boolean + added_lines?: number; removed_lines?: number + diff?: string[]; error?: string + old_backed_up_at?: string; new_backed_up_at?: string +} + +const DEVICE_ICON: Record = { + SWITCH: 'πŸ”€', ROUTER: 'πŸ”—', FIREWALL: 'πŸ›‘οΈ', LOAD_BALANCER: 'βš–οΈ', +} +const VENDOR_COLOR: Record = { + CISCO: '#1ba0d7', HUAWEI: '#cf0a2c', JUNIPER: '#84bd00', + PIOLINK: '#003087', SECUI: '#0066cc', RADWARE: '#00a3e0', +} + +export default function NetworkConsole() { + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [backing, setBacking] = useState(null) + const [diffDev, setDiffDev] = useState(null) + const [diff, setDiff] = useState(null) + const [msg, setMsg] = useState('') + const [filter, setFilter] = useState('') + + useEffect(() => { + guardiaApi.get('/api/network/devices') + .then(r => setDevices(r.data)) + .finally(() => setLoading(false)) + }, []) + + const doBackup = async (id: number, name: string) => { + setBacking(id); setMsg('') + try { + const r = await guardiaApi.post(`/api/network/devices/${id}/backup`) + const res: BackupResult = r.data + const changed = res.changed_lines ? ` (λ³€κ²½ ${res.changed_lines}쀄)` : '' + setMsg(`βœ” ${name} λ°±μ—… μ™„λ£Œ${changed}`) + setDevices(prev => prev.map(d => + d.id === id ? { ...d, last_backup_at: res.backed_up_at ?? new Date().toISOString() } : d + )) + } catch (e: any) { + setMsg(`✘ λ°±μ—… μ‹€νŒ¨: ${e.response?.data?.detail ?? e.message}`) + } finally { setBacking(null) } + } + + const showDiff = async (id: number) => { + if (diffDev === id) { setDiffDev(null); setDiff(null); return } + setDiffDev(id); setDiff(null) + try { + const r = await guardiaApi.get(`/api/network/devices/${id}/diff`) + setDiff(r.data) + } catch (e: any) { + setDiff({ success: false, changed: false, error: e.response?.data?.detail ?? e.message }) + } + } + + const daysSince = (iso?: string | null) => { + if (!iso) return null + const diff = (Date.now() - new Date(iso).getTime()) / 86400000 + return Math.floor(diff) + } + + const filtered = devices.filter(d => + !filter || d.device_name.toLowerCase().includes(filter.toLowerCase()) + || d.vendor.toLowerCase().includes(filter.toLowerCase()) + || d.device_type.toLowerCase().includes(filter.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 ( +
+ {/* μš”μ•½ */} +
+ {[ + { label: '전체 μž₯λΉ„', value: devices.length, color: '#1e293b' }, + { label: 'λ―Έλ°±μ—…', value: noBackup, color: '#ef4444' }, + { label: '7일 이상 λ―Έλ°±μ—…', value: stale, color: '#f59e0b' }, + { label: '정상', value: devices.length - noBackup - stale, color: '#22c55e' }, + ].map(c => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+ + {msg && ( +
+ {msg} +
+ )} + + {/* 검색 */} +
+ setFilter(e.target.value)} + placeholder="μž₯λΉ„λͺ… / 벀더 / νƒ€μž… 검색..." + style={{ padding: '8px 14px', border: '1px solid #e2e8f0', borderRadius: 8, + fontSize: 13, width: 300, outline: 'none' }} + /> +
+ + {/* μž₯λΉ„ ν…Œμ΄λΈ” */} +
+ + + + {['μž₯λΉ„λͺ…','νƒ€μž…','벀더/λͺ¨λΈ','μœ„μΉ˜','졜근 λ°±μ—…','μƒνƒœ','μ•‘μ…˜'].map(h => ( + + ))} + + + + {filtered.map(d => { + const days = daysSince(d.last_backup_at) + const backupStatus = !d.last_backup_at ? 'none' + : (days ?? 0) > 7 ? 'stale' : 'ok' + return ( + <> + + + + + + + + + + {diffDev === d.id && diff && ( + + + + )} + + ) + })} + {filtered.length === 0 && ( + + )} + +
+ {h} +
+ {DEVICE_ICON[d.device_type] ?? 'πŸ”§'} {d.device_name} + {d.device_type} + + {d.vendor} + + {d.model && + {d.model} + } + + {d.location ?? '-'} + + {!d.last_backup_at + ? λ―Έλ°±μ—… + : 7 ? '#f59e0b' : '#64748b' }}> + {days}일 μ „ + } + + {backupStatus === 'ok' && βœ” 정상} + {backupStatus === 'stale' && ⚠ κ°±μ‹  ν•„μš”} + {backupStatus === 'none' && ✘ λ―Έλ°±μ—…} + +
+ + +
+
+ {diff.success ? ( + diff.changed ? ( +
+
+ ⚑ μ„€μ • λ³€κ²½ 감지 β€” μΆ”κ°€ {diff.added_lines}쀄 / 제거 {diff.removed_lines}쀄 +
+
+                                {(diff.diff ?? []).join('\n')}
+                              
+
+ ) : ( + βœ” λ³€κ²½ μ—†μŒ + ) + ) : ( + {diff.error} + )} +
+ {devices.length === 0 ? 'λ“±λ‘λœ λ„€νŠΈμ›Œν¬ μž₯λΉ„κ°€ μ—†μŠ΅λ‹ˆλ‹€.' : '검색 κ²°κ³Ό μ—†μŒ'} +
+
+
+ ) +} diff --git a/manual/16_API_λͺ…μ„Έμ„œ.md b/manual/16_API_λͺ…μ„Έμ„œ.md index 52a2dd36..6ab98038 100644 --- a/manual/16_API_λͺ…μ„Έμ„œ.md +++ b/manual/16_API_λͺ…μ„Έμ„œ.md @@ -1,6 +1,6 @@ # GUARDiA ITSM β€” 전체 κΈ°λŠ₯ λͺ©λ‘ 및 API λͺ…μ„Έμ„œ -> **버전:** 2.1.0 | **총 라우트:** 617개 | **기쀀일:** 2026-05-31 +> **버전:** 2.2.0 | **총 라우트:** 617개 | **기쀀일:** 2026-05-31 | Manager/Messenger UI 연동 μ™„λ£Œ > **Base URL:** `http://localhost:8001` > **인증:** JWT Bearer Token (`POST /api/auth/login` β†’ `access_token`) diff --git a/manual/39_DR_λ„€νŠΈμ›Œν¬μž₯λΉ„_CSAP_μš΄μ˜κ°€μ΄λ“œ.md b/manual/39_DR_λ„€νŠΈμ›Œν¬μž₯λΉ„_CSAP_μš΄μ˜κ°€μ΄λ“œ.md index f312460f..0e37cf1d 100644 --- a/manual/39_DR_λ„€νŠΈμ›Œν¬μž₯λΉ„_CSAP_μš΄μ˜κ°€μ΄λ“œ.md +++ b/manual/39_DR_λ„€νŠΈμ›Œν¬μž₯λΉ„_CSAP_μš΄μ˜κ°€μ΄λ“œ.md @@ -537,4 +537,31 @@ Authorization: Bearer {token} --- +--- + +## 5. Manager / Messenger UI 연동 ν˜„ν™© (2026-05-31 μΆ”κ°€) + +### GUARDiA Manager (포트 8090) + +| 메뉴 경둜 | URL | κΈ°λŠ₯ | +|----------|-----|------| +| 운영 κ΄€μ œ β†’ DR μž¬ν•΄λ³΅κ΅¬ | `/dr` | μ‹œλ‚˜λ¦¬μ˜€ ν˜„ν™©, RTO/RPO, 볡ꡬ ν…ŒμŠ€νŠΈ μ‹€ν–‰ | +| 운영 κ΄€μ œ β†’ λ„€νŠΈμ›Œν¬ μž₯λΉ„ | `/network` | μž₯λΉ„ λͺ©λ‘, λ°±μ—… μ‹€ν–‰, μ„€μ • diff | +| 운영 κ΄€μ œ β†’ CSAP 점검 | `/csap` | μ€€μˆ˜μœ¨ λŒ€μ‹œλ³΄λ“œ, 점검 μ‹€ν–‰, Excel λ‹€μš΄λ‘œλ“œ | + +### GUARDiA Messenger (λͺ¨λ°”일 μ•±) + +| νƒ­ | ν™”λ©΄ | κΈ°λŠ₯ | +|----|------|------| +| πŸ›‘οΈ DR | `app/(tabs)/dr.tsx` | μ‹œλ‚˜λ¦¬μ˜€ μƒνƒœ, RTO 싀적, 볡ꡬ ν…ŒμŠ€νŠΈ | +| πŸ”€ λ„€νŠΈμ›Œν¬ | `app/(tabs)/network.tsx` | μž₯λΉ„ λͺ©λ‘, λ°±μ—… μ‹€ν–‰, μƒνƒœ 확인 | + +### μ‹ κ·œ hooks + +| 파일 | κΈ°λŠ₯ | +|------|------| +| `hooks/useBiometric.ts` | μ§€λ¬Έ/Face ID 생체인증 | +| `hooks/useOfflineCache.ts` | μ˜€ν”„λΌμΈ μΊμ‹œ (SecureStore 기반) | + +--- *Copyright Β© 2026 GUARDiA All Rights Reserved.*