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
de1e2263b6
commit
b06a35c512
84
app/(tabs)/_layout.tsx
Normal file
84
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/(tabs)/dr.tsx
Normal file
149
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/(tabs)/network.tsx
Normal file
184
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
hooks/useBiometric.ts
Normal file
25
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
hooks/useOfflineCache.ts
Normal file
62
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
services/api.ts
Normal file
88
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
|
||||
Loading…
Reference in New Issue
Block a user