feat(ui): Manager DR·네트워크·CSAP 관제 + Messenger DR·네트워크 화면 구현
## 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 <noreply@anthropic.com>
This commit is contained in:
parent
b217d101f0
commit
8fbb64a12a
84
app/app/(tabs)/_layout.tsx
Normal file
84
app/app/(tabs)/_layout.tsx
Normal file
@ -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 (
|
||||||
|
<View style={[tab.wrap, focused && tab.active]}>
|
||||||
|
<Text style={tab.icon}>{icon}</Text>
|
||||||
|
<Text style={[tab.label, focused && tab.labelActive]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: COLORS.gnbBg },
|
||||||
|
headerTintColor: '#fff',
|
||||||
|
headerTitleStyle: { fontWeight: '700' },
|
||||||
|
tabBarStyle: { backgroundColor: '#fff', borderTopColor: COLORS.border, height: 60 },
|
||||||
|
tabBarShowLabel: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: '대시보드',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="📊" label="대시보드" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="sr"
|
||||||
|
options={{
|
||||||
|
title: '서비스 요청',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="📋" label="SR" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="chat"
|
||||||
|
options={{
|
||||||
|
title: 'AI 챗봇',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="🤖" label="AI" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="notifications"
|
||||||
|
options={{
|
||||||
|
title: '알림',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="🔔" label="알림" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="dr"
|
||||||
|
options={{
|
||||||
|
title: 'DR 관제',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="🛡️" label="DR" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="network"
|
||||||
|
options={{
|
||||||
|
title: '네트워크',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="🔀" label="네트워크" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: '설정',
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon icon="⚙️" label="설정" focused={focused} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
app/app/(tabs)/dr.tsx
Normal file
149
app/app/(tabs)/dr.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
PASS: '#22c55e', FAIL: '#ef4444', PARTIAL: '#f59e0b', RUNNING: '#4f6ef7',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DRScreen() {
|
||||||
|
const [dash, setDash] = useState<DRDash | null>(null)
|
||||||
|
const [rto, setRto] = useState<RtoRpo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refresh, setRefresh] = useState(false)
|
||||||
|
const [running, setRunning] = useState<number | null>(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 (
|
||||||
|
<View style={s.center}>
|
||||||
|
<ActivityIndicator color={COLORS.accent} size="large" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
const scenarios = rto?.scenarios ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={s.container}
|
||||||
|
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}>
|
||||||
|
|
||||||
|
{/* 요약 카드 */}
|
||||||
|
<View style={s.row}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<View key={c.label} style={s.statCard}>
|
||||||
|
<Text style={[s.statNum, { color: c.color }]}>{c.value}</Text>
|
||||||
|
<Text style={s.statLabel}>{c.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 시나리오 목록 */}
|
||||||
|
<Text style={s.sectionTitle}>DR 시나리오 (RTO/RPO)</Text>
|
||||||
|
{scenarios.length === 0
|
||||||
|
? <Text style={s.empty}>등록된 DR 시나리오가 없습니다.</Text>
|
||||||
|
: scenarios.map(sc => (
|
||||||
|
<View key={sc.scenario_id} style={s.card}>
|
||||||
|
<View style={s.cardRow}>
|
||||||
|
<Text style={s.cardName}>{sc.scenario_name}</Text>
|
||||||
|
{sc.last_test_result && (
|
||||||
|
<View style={[s.badge, { backgroundColor: STATUS_COLOR[sc.last_test_result] ?? '#94a3b8' }]}>
|
||||||
|
<Text style={s.badgeText}>{sc.last_test_result}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={s.cardMeta}>
|
||||||
|
<Text style={s.metaText}>RTO 목표: {sc.rto_target ? `${sc.rto_target}분` : '-'}</Text>
|
||||||
|
<Text style={s.metaText}>실적: {sc.rto_actual_avg != null ? `${sc.rto_actual_avg}분` : '기록없음'}</Text>
|
||||||
|
{sc.rto_met === true && <Text style={[s.metaText, { color: '#22c55e' }]}>✔ 충족</Text>}
|
||||||
|
{sc.rto_met === false && <Text style={[s.metaText, { color: '#ef4444' }]}>✘ 초과</Text>}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, running === sc.scenario_id && s.btnDisabled]}
|
||||||
|
onPress={() => handleTest(sc.scenario_id, sc.scenario_name)}
|
||||||
|
disabled={running === sc.scenario_id}>
|
||||||
|
<Text style={s.btnText}>
|
||||||
|
{running === sc.scenario_id ? '실행 중...' : '복구 테스트 실행'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 최근 테스트 이력 */}
|
||||||
|
<Text style={s.sectionTitle}>최근 테스트 이력</Text>
|
||||||
|
{(dash?.recent_tests ?? []).slice(0, 5).map(t => (
|
||||||
|
<View key={t.test_id} style={[s.card, { flexDirection: 'row', alignItems: 'center', paddingVertical: 10 }]}>
|
||||||
|
<Text style={[s.metaText, { flex: 1 }]}>#{t.test_id} {t.test_type}</Text>
|
||||||
|
<View style={[s.badge, { backgroundColor: STATUS_COLOR[t.status] ?? '#94a3b8' }]}>
|
||||||
|
<Text style={s.badgeText}>{t.status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={{ height: 40 }} />
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
184
app/app/(tabs)/network.tsx
Normal file
184
app/app/(tabs)/network.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
SWITCH: '🔀', ROUTER: '🔗', FIREWALL: '🛡️', LOAD_BALANCER: '⚖️',
|
||||||
|
}
|
||||||
|
const VENDOR_COLOR: Record<string, string> = {
|
||||||
|
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<NetworkDevice[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refresh, setRefresh] = useState(false)
|
||||||
|
const [backing, setBacking] = useState<number | null>(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 (
|
||||||
|
<View style={s.center}><ActivityIndicator color={COLORS.accent} size="large" /></View>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={s.container}
|
||||||
|
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}>
|
||||||
|
|
||||||
|
{/* 요약 */}
|
||||||
|
<View style={s.row}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<View key={c.label} style={s.statCard}>
|
||||||
|
<Text style={[s.statNum, { color: c.color }]}>{c.value}</Text>
|
||||||
|
<Text style={s.statLabel}>{c.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<View style={s.searchWrap}>
|
||||||
|
<TextInput
|
||||||
|
style={s.searchInput}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
placeholder="장비명 / 벤더 검색..."
|
||||||
|
placeholderTextColor={COLORS.muted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 장비 목록 */}
|
||||||
|
<Text style={s.sectionTitle}>네트워크 장비 ({filtered.length}개)</Text>
|
||||||
|
{filtered.length === 0
|
||||||
|
? <Text style={s.empty}>{devices.length === 0 ? '등록된 장비가 없습니다.' : '검색 결과 없음'}</Text>
|
||||||
|
: filtered.map(d => {
|
||||||
|
const days = daysSince(d.last_backup_at)
|
||||||
|
const backupOk = days !== null && days <= 7
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={d.id} style={s.card}>
|
||||||
|
<View style={s.cardTop}>
|
||||||
|
<Text style={s.icon}>{DEVICE_ICON[d.device_type] ?? '🔧'}</Text>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={s.deviceName}>{d.device_name}</Text>
|
||||||
|
<Text style={s.vendorText} numberOfLines={1}>
|
||||||
|
<Text style={{ color: VENDOR_COLOR[d.vendor] ?? COLORS.muted, fontWeight: '700' }}>
|
||||||
|
{d.vendor}
|
||||||
|
</Text>
|
||||||
|
{d.model ? ` · ${d.model}` : ''}
|
||||||
|
{d.location ? ` 📍${d.location}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[s.statusDot, { backgroundColor: backupOk ? '#22c55e' : (days !== null ? '#f59e0b' : '#ef4444') }]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={s.backupRow}>
|
||||||
|
<Text style={s.backupText}>
|
||||||
|
{!d.last_backup_at
|
||||||
|
? '⚠ 미백업'
|
||||||
|
: days! > 7
|
||||||
|
? `⚠ ${days}일 전 백업 (갱신 필요)`
|
||||||
|
: `✔ ${days}일 전 백업`}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.backupBtn, backing === d.id && s.backupBtnDisabled]}
|
||||||
|
onPress={() => handleBackup(d.id, d.device_name)}
|
||||||
|
disabled={backing === d.id}>
|
||||||
|
<Text style={s.backupBtnText}>
|
||||||
|
{backing === d.id ? '백업 중...' : '백업'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<View style={{ height: 40 }} />
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
25
app/hooks/useBiometric.ts
Normal file
25
app/hooks/useBiometric.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import * as LocalAuth from 'expo-local-authentication'
|
||||||
|
|
||||||
|
export async function isBiometricAvailable(): Promise<boolean> {
|
||||||
|
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 ?? '인증 실패' }
|
||||||
|
}
|
||||||
62
app/hooks/useOfflineCache.ts
Normal file
62
app/hooks/useOfflineCache.ts
Normal file
@ -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<T>(
|
||||||
|
cacheKey: string,
|
||||||
|
fetcher: () => Promise<{ data: T }>,
|
||||||
|
options: CacheOptions = {}
|
||||||
|
) {
|
||||||
|
const { ttlMs = 5 * 60 * 1000 } = options
|
||||||
|
const [data, setData] = useState<T | null>(null)
|
||||||
|
const [isOffline, setOffline] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [cachedAt, setCachedAt] = useState<Date | null>(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 }
|
||||||
|
}
|
||||||
88
app/services/api.ts
Normal file
88
app/services/api.ts
Normal file
@ -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
|
||||||
67
manager/frontend/src/App.tsx
Normal file
67
manager/frontend/src/App.tsx
Normal file
@ -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 (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '60vh', color: '#94a3b8', gap: 10 }}>
|
||||||
|
<span style={{ width: 16, height: 16, border: '2px solid #4f6ef7',
|
||||||
|
borderTopColor: 'transparent', borderRadius: '50%',
|
||||||
|
animation: 'spin .6s linear infinite', display: 'inline-block' }} />
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="servers" element={<Servers />} />
|
||||||
|
<Route path="cmdb" element={<CMDB />} />
|
||||||
|
<Route path="deployments" element={<Deployments />} />
|
||||||
|
<Route path="repos" element={<Repos />} />
|
||||||
|
<Route path="users" element={<Users />} />
|
||||||
|
<Route path="institutions" element={<Institutions />} />
|
||||||
|
<Route path="api-keys" element={<ApiKeys />} />
|
||||||
|
<Route path="audit" element={<AuditLog />} />
|
||||||
|
<Route path="llm" element={<LLMManager />} />
|
||||||
|
<Route path="config/env" element={<ConfigEnv />} />
|
||||||
|
<Route path="config/nginx" element={<ConfigNginx />} />
|
||||||
|
<Route path="notifications" element={<Notifications />} />
|
||||||
|
<Route path="licenses" element={<Licenses />} />
|
||||||
|
<Route path="export-import" element={<ExportImport />} />
|
||||||
|
<Route path="dr" element={<DrConsole />} />
|
||||||
|
<Route path="network" element={<NetworkConsole />} />
|
||||||
|
<Route path="csap" element={<CsapConsole />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
manager/frontend/src/components/layout/AppLayout.tsx
Normal file
52
manager/frontend/src/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import { GNB } from './GNB'
|
||||||
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
|
'/': '통합 운영 대시보드',
|
||||||
|
'/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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
|
<GNB />
|
||||||
|
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<main style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* 페이지 타이틀 바 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 28px', background: '#fff',
|
||||||
|
borderBottom: '1px solid #e2e8f0',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<h1 style={{ fontSize: 16, fontWeight: 700, color: '#1e293b', margin: 0 }}>{title}</h1>
|
||||||
|
</div>
|
||||||
|
{/* 콘텐츠 */}
|
||||||
|
<div style={{ padding: 24, flex: 1 }} className="animate-in">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
manager/frontend/src/components/layout/Sidebar.tsx
Normal file
95
manager/frontend/src/components/layout/Sidebar.tsx
Normal file
@ -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 (
|
||||||
|
<NavLink to={item.path!} style={({ isActive }) => ({
|
||||||
|
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',
|
||||||
|
})}>
|
||||||
|
<span style={{ width: 18, textAlign: 'center' }}>{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setOpen(o => !o)} style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '8px 16px', fontSize: 13, color: '#1e293b', background: 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 18, textAlign: 'center' }}>{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 10, transition: 'transform .2s',
|
||||||
|
transform: open ? 'rotate(180deg)' : 'rotate(0)' }}>▾</span>
|
||||||
|
</button>
|
||||||
|
{open && item.children.map(c => (
|
||||||
|
<NavLink key={c.path} to={c.path!} style={({ isActive }) => ({
|
||||||
|
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}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside style={{
|
||||||
|
width: 'var(--sidebar-w)', background: 'var(--sidebar-bg)',
|
||||||
|
borderRight: '1px solid var(--border)', height: '100%',
|
||||||
|
display: 'flex', flexDirection: 'column', overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '14px 0', flex: 1 }}>
|
||||||
|
{NAV.map((item, i) => <NavGroup key={i} item={item} />)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
249
manager/frontend/src/pages/CsapConsole.tsx
Normal file
249
manager/frontend/src/pages/CsapConsole.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
A: '#22c55e', B: '#4f6ef7', C: '#f59e0b', D: '#ef4444',
|
||||||
|
}
|
||||||
|
const SEV_COLOR: Record<string, string> = {
|
||||||
|
HIGH: '#ef4444', MEDIUM: '#f59e0b', LOW: '#22c55e',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CsapConsole() {
|
||||||
|
const [sites, setSites] = useState<CsapSite[]>([])
|
||||||
|
const [items, setItems] = useState<CsapItem[]>([])
|
||||||
|
const [selected, setSelected] = useState<CsapSite | null>(null)
|
||||||
|
const [scanning, setScanning] = useState<number | null>(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<Record<string, { total: number; auto: number }>>(
|
||||||
|
(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 <div style={{ color: '#94a3b8', padding: 40 }}>로딩 중...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 점검 항목 요약 */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
|
||||||
|
{Object.entries(catSummary).map(([cat, s]) => (
|
||||||
|
<div key={cat} style={{ background: '#fff', border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 10, padding: '14px 20px', flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>{cat} 보안</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color: '#1e293b', marginTop: 4 }}>
|
||||||
|
{s.total}개
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
|
||||||
|
자동 {s.auto}개 / 수동 {s.total - s.auto}개
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 10, padding: '14px 20px', flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>전체 항목</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color: '#1e293b', marginTop: 4 }}>
|
||||||
|
{items.length}개
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
|
||||||
|
자동 {items.filter(i => i.auto).length}개 / 수동 {items.filter(i => !i.auto).length}개
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<div style={{ padding: '10px 16px', borderRadius: 8, marginBottom: 16,
|
||||||
|
background: msg.startsWith('✔') ? '#f0fdf4' : '#fef2f2',
|
||||||
|
color: msg.startsWith('✔') ? '#16a34a' : '#dc2626',
|
||||||
|
border: `1px solid ${msg.startsWith('✔') ? '#bbf7d0' : '#fecaca'}`,
|
||||||
|
fontSize: 13 }}>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 기관별 준수율 테이블 */}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
||||||
|
overflow: 'hidden', marginBottom: 20 }}>
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid #f1f5f9',
|
||||||
|
fontWeight: 600, fontSize: 13, color: '#1e293b' }}>
|
||||||
|
기관별 CSAP 준수율
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8fafc' }}>
|
||||||
|
{['기관 ID','준수율','등급','통과/전체','마지막 점검','액션'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 14px', textAlign: 'left',
|
||||||
|
fontWeight: 600, color: '#475569', fontSize: 12,
|
||||||
|
borderBottom: '1px solid #e2e8f0' }}>{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sites.map(s => (
|
||||||
|
<tr key={s.inst_id}
|
||||||
|
style={{ borderBottom: '1px solid #f1f5f9',
|
||||||
|
background: selected?.inst_id === s.inst_id ? '#f8fafc' : undefined,
|
||||||
|
cursor: 'pointer' }}
|
||||||
|
onClick={() => setSelected(selected?.inst_id === s.inst_id ? null : s)}>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#64748b' }}>기관 #{s.inst_id}</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, maxWidth: 120, height: 6, background: '#f1f5f9',
|
||||||
|
borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${s.compliance_rate}%`, height: '100%',
|
||||||
|
background: GRADE_COLOR[s.grade] ?? '#4f6ef7', borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 700, color: GRADE_COLOR[s.grade] ?? '#1e293b',
|
||||||
|
minWidth: 40 }}>
|
||||||
|
{s.compliance_rate}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
<span style={{ padding: '3px 12px', borderRadius: 12, fontSize: 12,
|
||||||
|
fontWeight: 700, color: '#fff',
|
||||||
|
background: GRADE_COLOR[s.grade] ?? '#94a3b8' }}>
|
||||||
|
{s.grade}등급
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#475569' }}>
|
||||||
|
{s.pass_count}/{s.total}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#64748b', fontSize: 12 }}>
|
||||||
|
{s.scanned_at ? new Date(s.scanned_at).toLocaleDateString('ko-KR') : '미점검'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<button onClick={() => runScan(s.inst_id)}
|
||||||
|
disabled={scanning === s.inst_id}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6,
|
||||||
|
background: scanning === s.inst_id ? '#e2e8f0' : '#4f6ef7',
|
||||||
|
color: scanning === s.inst_id ? '#94a3b8' : '#fff',
|
||||||
|
border: 'none', cursor: scanning === s.inst_id ? 'not-allowed' : 'pointer' }}>
|
||||||
|
{scanning === s.inst_id ? '점검 중..' : '점검'}
|
||||||
|
</button>
|
||||||
|
{s.scan_id && (
|
||||||
|
<button onClick={() => downloadExcel(s.scan_id)}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6,
|
||||||
|
background: '#f1f5f9', color: '#475569',
|
||||||
|
border: '1px solid #e2e8f0', cursor: 'pointer' }}>
|
||||||
|
Excel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{sites.length === 0 && (
|
||||||
|
<tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>
|
||||||
|
점검 이력이 없습니다. 기관을 선택해 점검을 실행하세요.
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 점검 항목 목록 */}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
||||||
|
overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid #f1f5f9',
|
||||||
|
fontWeight: 600, fontSize: 13, color: '#1e293b' }}>
|
||||||
|
CSAP 점검 항목 목록 ({items.length}개)
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8fafc', position: 'sticky', top: 0 }}>
|
||||||
|
{['항목ID','카테고리','항목명','심각도','점검방식'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '8px 14px', textAlign: 'left',
|
||||||
|
fontWeight: 600, color: '#475569',
|
||||||
|
borderBottom: '1px solid #e2e8f0' }}>{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(i => (
|
||||||
|
<tr key={i.id} style={{ borderBottom: '1px solid #f8fafc' }}>
|
||||||
|
<td style={{ padding: '7px 14px', fontWeight: 700, color: '#4f6ef7' }}>{i.id}</td>
|
||||||
|
<td style={{ padding: '7px 14px', color: '#64748b' }}>{i.cat}</td>
|
||||||
|
<td style={{ padding: '7px 14px', color: '#1e293b' }}>{i.name}</td>
|
||||||
|
<td style={{ padding: '7px 14px' }}>
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 10,
|
||||||
|
fontWeight: 700, color: '#fff',
|
||||||
|
background: SEV_COLOR[i.sev] ?? '#94a3b8' }}>
|
||||||
|
{i.sev}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 14px' }}>
|
||||||
|
<span style={{ fontSize: 11, color: i.auto ? '#22c55e' : '#f59e0b',
|
||||||
|
fontWeight: 600 }}>
|
||||||
|
{i.auto ? '⚡ 자동' : '📋 수동'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
207
manager/frontend/src/pages/DrConsole.tsx
Normal file
207
manager/frontend/src/pages/DrConsole.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
PASS: '#22c55e', FAIL: '#ef4444', PARTIAL: '#f59e0b',
|
||||||
|
RUNNING: '#4f6ef7', UNTESTED: '#94a3b8',
|
||||||
|
}
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
SERVER_FAILURE: '서버 장애', SITE_FAILURE: '사이트 장애', DATA_CORRUPTION: '데이터 손상',
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ status }: { status: string }) {
|
||||||
|
const color = STATUS_COLOR[status] ?? '#94a3b8'
|
||||||
|
return (
|
||||||
|
<span style={{ padding: '2px 10px', borderRadius: 12, fontSize: 11,
|
||||||
|
fontWeight: 700, color: '#fff', background: color }}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, value, sub, color }: { title: string; value: number; sub?: string; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
||||||
|
padding: '18px 22px', flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 6 }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: color ?? '#1e293b' }}>{value}</div>
|
||||||
|
{sub && <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 4 }}>{sub}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DrConsole() {
|
||||||
|
const [dashboard, setDashboard] = useState<DRDashboard | null>(null)
|
||||||
|
const [rtoRpo, setRtoRpo] = useState<RtoRpo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [running, setRunning] = useState<number | null>(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 <div style={{ color: '#94a3b8', padding: 40 }}>로딩 중...</div>
|
||||||
|
|
||||||
|
const scenarios = rtoRpo?.scenarios ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 요약 카드 */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
|
||||||
|
<Card title="전체 시나리오" value={dashboard?.total_scenarios ?? 0} />
|
||||||
|
<Card title="PASS" value={dashboard?.pass_count ?? 0} color="#22c55e" sub="최근 테스트 통과" />
|
||||||
|
<Card title="FAIL" value={dashboard?.fail_count ?? 0} color="#ef4444" sub="조치 필요" />
|
||||||
|
<Card title="미테스트" value={dashboard?.untested_count ?? 0} color="#f59e0b" sub="테스트 필요" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<div style={{ padding: '10px 16px', borderRadius: 8, marginBottom: 16,
|
||||||
|
background: msg.includes('실패') ? '#fef2f2' : '#f0fdf4',
|
||||||
|
color: msg.includes('실패') ? '#dc2626' : '#16a34a',
|
||||||
|
border: `1px solid ${msg.includes('실패') ? '#fecaca' : '#bbf7d0'}`,
|
||||||
|
fontSize: 13 }}>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 시나리오 테이블 */}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
||||||
|
marginBottom: 24, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid #f1f5f9',
|
||||||
|
fontWeight: 600, fontSize: 13, color: '#1e293b' }}>
|
||||||
|
DR 시나리오 목록 (RTO/RPO 현황)
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8fafc' }}>
|
||||||
|
{['시나리오명','유형','RTO 목표','RTO 실적','충족 여부','마지막 테스트','상태','액션'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 14px', textAlign: 'left',
|
||||||
|
fontWeight: 600, color: '#475569', fontSize: 12, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{scenarios.map(sc => (
|
||||||
|
<tr key={sc.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<td style={{ padding: '10px 14px', fontWeight: 600, color: '#1e293b' }}>{sc.name}</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#64748b' }}>
|
||||||
|
{TYPE_LABEL[sc.scenario_type] ?? sc.scenario_type}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#475569' }}>
|
||||||
|
{sc.rto_minutes ? `${sc.rto_minutes}분` : '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', fontWeight: 600,
|
||||||
|
color: sc.rto_actual_avg ? '#1e293b' : '#94a3b8' }}>
|
||||||
|
{sc.rto_actual_avg != null ? `${sc.rto_actual_avg}분` : '기록 없음'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
{sc.rto_met === true && <span style={{ color:'#22c55e', fontWeight:700 }}>✔ 충족</span>}
|
||||||
|
{sc.rto_met === false && <span style={{ color:'#ef4444', fontWeight:700 }}>✘ 초과</span>}
|
||||||
|
{sc.rto_met == null && <span style={{ color:'#94a3b8' }}>-</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#64748b', fontSize: 12 }}>
|
||||||
|
{sc.last_test_at ? new Date(sc.last_test_at).toLocaleDateString('ko-KR') : '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
<Badge status={sc.last_test_result ?? 'UNTESTED'} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => runTest(sc.id)}
|
||||||
|
disabled={running === sc.id}
|
||||||
|
style={{ padding: '5px 12px', fontSize: 12, borderRadius: 6,
|
||||||
|
background: running === sc.id ? '#e2e8f0' : '#4f6ef7',
|
||||||
|
color: running === sc.id ? '#94a3b8' : '#fff',
|
||||||
|
border: 'none', cursor: running === sc.id ? 'not-allowed' : 'pointer' }}>
|
||||||
|
{running === sc.id ? '실행 중...' : '테스트 실행'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{scenarios.length === 0 && (
|
||||||
|
<tr><td colSpan={8} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>
|
||||||
|
등록된 DR 시나리오가 없습니다.
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최근 테스트 이력 */}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid #f1f5f9',
|
||||||
|
fontWeight: 600, fontSize: 13, color: '#1e293b' }}>
|
||||||
|
최근 테스트 이력
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8fafc' }}>
|
||||||
|
{['테스트 ID','유형','상태','시작일시'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 14px', textAlign: 'left',
|
||||||
|
fontWeight: 600, color: '#475569', fontSize: 12, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(dashboard?.recent_tests ?? []).map(t => (
|
||||||
|
<tr key={t.test_id} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#64748b' }}>#{t.test_id}</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#475569' }}>{t.test_type}</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}><Badge status={t.status} /></td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#64748b', fontSize: 12 }}>
|
||||||
|
{new Date(t.started_at).toLocaleString('ko-KR')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
234
manager/frontend/src/pages/NetworkConsole.tsx
Normal file
234
manager/frontend/src/pages/NetworkConsole.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
SWITCH: '🔀', ROUTER: '🔗', FIREWALL: '🛡️', LOAD_BALANCER: '⚖️',
|
||||||
|
}
|
||||||
|
const VENDOR_COLOR: Record<string, string> = {
|
||||||
|
CISCO: '#1ba0d7', HUAWEI: '#cf0a2c', JUNIPER: '#84bd00',
|
||||||
|
PIOLINK: '#003087', SECUI: '#0066cc', RADWARE: '#00a3e0',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NetworkConsole() {
|
||||||
|
const [devices, setDevices] = useState<NetworkDevice[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [backing, setBacking] = useState<number | null>(null)
|
||||||
|
const [diffDev, setDiffDev] = useState<number | null>(null)
|
||||||
|
const [diff, setDiff] = useState<DiffResult | null>(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 <div style={{ color: '#94a3b8', padding: 40 }}>로딩 중...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 요약 */}
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={c.label} style={{ background: '#fff', border: '1px solid #e2e8f0',
|
||||||
|
borderRadius: 10, padding: '16px 22px', flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>{c.label}</div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 700, color: c.color, marginTop: 4 }}>{c.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<div style={{ padding: '10px 16px', borderRadius: 8, marginBottom: 16,
|
||||||
|
background: msg.startsWith('✔') ? '#f0fdf4' : '#fef2f2',
|
||||||
|
color: msg.startsWith('✔') ? '#16a34a' : '#dc2626',
|
||||||
|
border: `1px solid ${msg.startsWith('✔') ? '#bbf7d0' : '#fecaca'}`,
|
||||||
|
fontSize: 13 }}>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<input
|
||||||
|
value={filter} onChange={e => setFilter(e.target.value)}
|
||||||
|
placeholder="장비명 / 벤더 / 타입 검색..."
|
||||||
|
style={{ padding: '8px 14px', border: '1px solid #e2e8f0', borderRadius: 8,
|
||||||
|
fontSize: 13, width: 300, outline: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 장비 테이블 */}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
|
||||||
|
overflow: 'hidden', marginBottom: 16 }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f8fafc' }}>
|
||||||
|
{['장비명','타입','벤더/모델','위치','최근 백업','상태','액션'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '10px 14px', textAlign: 'left',
|
||||||
|
fontWeight: 600, color: '#475569', fontSize: 12,
|
||||||
|
borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map(d => {
|
||||||
|
const days = daysSince(d.last_backup_at)
|
||||||
|
const backupStatus = !d.last_backup_at ? 'none'
|
||||||
|
: (days ?? 0) > 7 ? 'stale' : 'ok'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr key={d.id} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||||
|
<td style={{ padding: '10px 14px', fontWeight: 600, color: '#1e293b' }}>
|
||||||
|
{DEVICE_ICON[d.device_type] ?? '🔧'} {d.device_name}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#475569' }}>{d.device_type}</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
<span style={{ fontWeight: 700, color: VENDOR_COLOR[d.vendor] ?? '#475569' }}>
|
||||||
|
{d.vendor}
|
||||||
|
</span>
|
||||||
|
{d.model && <span style={{ color: '#94a3b8', fontSize: 11, marginLeft: 6 }}>
|
||||||
|
{d.model}
|
||||||
|
</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', color: '#64748b', fontSize: 12 }}>
|
||||||
|
{d.location ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', fontSize: 12 }}>
|
||||||
|
{!d.last_backup_at
|
||||||
|
? <span style={{ color: '#ef4444', fontWeight: 600 }}>미백업</span>
|
||||||
|
: <span style={{ color: (days ?? 0) > 7 ? '#f59e0b' : '#64748b' }}>
|
||||||
|
{days}일 전
|
||||||
|
</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
{backupStatus === 'ok' && <span style={{ color:'#22c55e', fontWeight:700 }}>✔ 정상</span>}
|
||||||
|
{backupStatus === 'stale' && <span style={{ color:'#f59e0b', fontWeight:700 }}>⚠ 갱신 필요</span>}
|
||||||
|
{backupStatus === 'none' && <span style={{ color:'#ef4444', fontWeight:700 }}>✘ 미백업</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button onClick={() => doBackup(d.id, d.device_name)}
|
||||||
|
disabled={backing === d.id}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6,
|
||||||
|
background: backing === d.id ? '#e2e8f0' : '#4f6ef7',
|
||||||
|
color: backing === d.id ? '#94a3b8' : '#fff',
|
||||||
|
border: 'none', cursor: backing === d.id ? 'not-allowed' : 'pointer' }}>
|
||||||
|
{backing === d.id ? '백업 중..' : '백업'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => showDiff(d.id)}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6,
|
||||||
|
background: diffDev === d.id ? '#1e293b' : '#f1f5f9',
|
||||||
|
color: diffDev === d.id ? '#fff' : '#475569',
|
||||||
|
border: '1px solid #e2e8f0', cursor: 'pointer' }}>
|
||||||
|
Diff
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{diffDev === d.id && diff && (
|
||||||
|
<tr key={`diff-${d.id}`}>
|
||||||
|
<td colSpan={7} style={{ padding: '12px 20px', background: '#f8fafc',
|
||||||
|
borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
{diff.success ? (
|
||||||
|
diff.changed ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: '#475569', marginBottom: 8 }}>
|
||||||
|
⚡ 설정 변경 감지 — 추가 {diff.added_lines}줄 / 제거 {diff.removed_lines}줄
|
||||||
|
</div>
|
||||||
|
<pre style={{ fontSize: 11, background: '#1e293b', color: '#e2e8f0',
|
||||||
|
borderRadius: 6, padding: 12, overflow: 'auto', maxHeight: 200,
|
||||||
|
margin: 0 }}>
|
||||||
|
{(diff.diff ?? []).join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12, color: '#22c55e' }}>✔ 변경 없음</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12, color: '#ef4444' }}>{diff.error}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>
|
||||||
|
{devices.length === 0 ? '등록된 네트워크 장비가 없습니다.' : '검색 결과 없음'}
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# GUARDiA ITSM — 전체 기능 목록 및 API 명세서
|
# 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`
|
> **Base URL:** `http://localhost:8001`
|
||||||
> **인증:** JWT Bearer Token (`POST /api/auth/login` → `access_token`)
|
> **인증:** JWT Bearer Token (`POST /api/auth/login` → `access_token`)
|
||||||
|
|
||||||
|
|||||||
@ -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.*
|
*Copyright © 2026 GUARDiA All Rights Reserved.*
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user