From 8fbb64a12a116db4f4120fb6a26dcc6a6af97ab8 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" 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/app/(tabs)/_layout.tsx | 84 ++++++ app/app/(tabs)/dr.tsx | 149 +++++++++++ app/app/(tabs)/network.tsx | 184 +++++++++++++ app/hooks/useBiometric.ts | 25 ++ app/hooks/useOfflineCache.ts | 62 +++++ app/services/api.ts | 88 +++++++ manager/frontend/src/App.tsx | 67 +++++ .../src/components/layout/AppLayout.tsx | 52 ++++ .../src/components/layout/Sidebar.tsx | 95 +++++++ manager/frontend/src/pages/CsapConsole.tsx | 249 ++++++++++++++++++ manager/frontend/src/pages/DrConsole.tsx | 207 +++++++++++++++ manager/frontend/src/pages/NetworkConsole.tsx | 234 ++++++++++++++++ manual/16_API_명세서.md | 2 +- manual/39_DR_네트워크장비_CSAP_운영가이드.md | 27 ++ 14 files changed, 1524 insertions(+), 1 deletion(-) create mode 100644 app/app/(tabs)/_layout.tsx create mode 100644 app/app/(tabs)/dr.tsx create mode 100644 app/app/(tabs)/network.tsx create mode 100644 app/hooks/useBiometric.ts create mode 100644 app/hooks/useOfflineCache.ts create mode 100644 app/services/api.ts create mode 100644 manager/frontend/src/App.tsx create mode 100644 manager/frontend/src/components/layout/AppLayout.tsx create mode 100644 manager/frontend/src/components/layout/Sidebar.tsx create mode 100644 manager/frontend/src/pages/CsapConsole.tsx create mode 100644 manager/frontend/src/pages/DrConsole.tsx create mode 100644 manager/frontend/src/pages/NetworkConsole.tsx 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.*