- 37개 파일 IP → zioinfo.co.kr 치환 (소스/매뉴얼/설정/하네스) - Manager DrConsole/NetworkConsole/CsapConsole 빌드 + /var/www/manager/ 배포 - 테스트: Manager HTTP 200, ITSM 신규 API 7개 전체 200 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
8.1 KiB
TypeScript
176 lines
8.1 KiB
TypeScript
import { useEffect, useState, useRef } from 'react'
|
|
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, RefreshControl, Animated } from 'react-native'
|
|
import { COLORS } from '../../constants/Config'
|
|
import { getNotifications, markNotificationRead } from '../../services/api'
|
|
import { useWebSocket } from '../../hooks/useWebSocket'
|
|
|
|
const TYPE_ICON: Record<string, string> = {
|
|
SR_CREATED:'📋', SR_UPDATED:'🔄', SR_COMPLETED:'✅',
|
|
INCIDENT:'🚨', SLA_BREACH:'⏰', DEPLOY_SUCCESS:'🚀',
|
|
DEPLOY_FAIL:'❌', LICENSE:'🔑', SYSTEM:'⚙️', DEFAULT:'🔔',
|
|
sr_created:'📋', sr_updated:'🔄', sr_completed:'✅',
|
|
deploy_notify:'🚀', incident_created:'🚨', sla_breach:'⏰',
|
|
anomaly_alert:'⚠️', batch_notify:'⚙️',
|
|
}
|
|
|
|
interface NotifItem {
|
|
id: string | number; type: string; title: string
|
|
message: string; is_read: boolean; created_at: string
|
|
source: 'api' | 'ws'
|
|
}
|
|
|
|
const SAMPLE: NotifItem[] = [
|
|
{ id:'s1', type:'SR_CREATED', title:'[샘플] SR 신규 접수',
|
|
message:'서버 재시작 요청이 접수되었습니다.',
|
|
is_read:false, created_at:new Date().toISOString(), source:'api' },
|
|
{ id:'s2', type:'DEPLOY_SUCCESS', title:'[샘플] 배포 완료',
|
|
message:'zioinfo-web 배포가 완료되었습니다.',
|
|
is_read:true, created_at:new Date(Date.now()-3600000).toISOString(), source:'api' },
|
|
]
|
|
|
|
const fmtTitle = (t: string) => ({
|
|
sr_created:'SR 신규 접수', sr_updated:'SR 상태 변경', sr_completed:'SR 완료',
|
|
deploy_notify:'배포 알림', incident_created:'인시던트 발생', sla_breach:'SLA 위반 경고',
|
|
SR_CREATED:'SR 신규 접수', INCIDENT:'인시던트', SLA_BREACH:'SLA 위반',
|
|
DEPLOY_SUCCESS:'배포 성공', DEPLOY_FAIL:'배포 실패',
|
|
} as Record<string,string>)[t] ?? t
|
|
|
|
const fmtMsg = (evt: { event_type:string; data:Record<string,unknown> }) => {
|
|
const d = evt.data ?? {}
|
|
if (d.title) return String(d.title)
|
|
if (d.message) return String(d.message)
|
|
if (d.sr_id) return `SR ${d.sr_id}: ${d.status ?? ''}`
|
|
return `${evt.event_type} 이벤트 수신`
|
|
}
|
|
|
|
export default function NotificationsScreen() {
|
|
const [items, setItems] = useState<NotifItem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [refresh, setRefresh] = useState(false)
|
|
const pulseAnim = useRef(new Animated.Value(1)).current
|
|
const { connected, lastEvent, events: wsEvents } = useWebSocket(['sr','deploy','sla','incident'])
|
|
|
|
useEffect(() => {
|
|
if (connected) {
|
|
Animated.loop(Animated.sequence([
|
|
Animated.timing(pulseAnim, { toValue:0.4, duration:900, useNativeDriver:true }),
|
|
Animated.timing(pulseAnim, { toValue:1.0, duration:900, useNativeDriver:true }),
|
|
])).start()
|
|
} else { pulseAnim.setValue(1) }
|
|
}, [connected])
|
|
|
|
useEffect(() => {
|
|
if (!lastEvent) return
|
|
const item: NotifItem = {
|
|
id:`ws-${Date.now()}`, type:lastEvent.event_type,
|
|
title:fmtTitle(lastEvent.event_type), message:fmtMsg(lastEvent),
|
|
is_read:false, created_at:(lastEvent.timestamp as string) || new Date().toISOString(),
|
|
source:'ws',
|
|
}
|
|
setItems(prev => [item, ...prev])
|
|
}, [lastEvent])
|
|
|
|
const load = async (r = false) => {
|
|
r ? setRefresh(true) : setLoading(true)
|
|
try {
|
|
const res = await getNotifications()
|
|
const api: NotifItem[] = (res.data.content ?? res.data.items ?? res.data ?? [])
|
|
.map((n: any) => ({ ...n, source:'api' as const }))
|
|
setItems(prev => [...prev.filter(i => i.source==='ws'), ...api])
|
|
} catch { if (!r) setItems(prev => prev.length===0 ? SAMPLE : prev) }
|
|
finally { setLoading(false); setRefresh(false) }
|
|
}
|
|
|
|
useEffect(() => { load() }, [])
|
|
|
|
const markRead = async (id: string | number) => {
|
|
setItems(prev => prev.map(n => n.id===id ? {...n, is_read:true} : n))
|
|
if (!String(id).startsWith('ws-')) try { await markNotificationRead(Number(id)) } catch {}
|
|
}
|
|
|
|
const unread = items.filter(n => !n.is_read).length
|
|
|
|
return (
|
|
<View style={{ flex:1, backgroundColor:COLORS.bg }}>
|
|
<View style={s.header}>
|
|
<View>
|
|
<Text style={s.title}>알림</Text>
|
|
<View style={s.wsRow}>
|
|
<Animated.View style={[s.dot, { opacity:pulseAnim, backgroundColor:connected?COLORS.success:'#94a3b8' }]} />
|
|
<Text style={[s.wsLabel, { color:connected?COLORS.success:'#94a3b8' }]}>
|
|
{connected ? 'GUARDiA 실시간 연결' : '연결 중...'}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
{unread>0 && <View style={s.badge}><Text style={s.badgeText}>{unread}</Text></View>}
|
|
</View>
|
|
|
|
{wsEvents.length>0 && wsEvents[0] && (
|
|
<View style={s.wsBanner}>
|
|
<Text style={s.wsIcon}>{TYPE_ICON[wsEvents[0]?.event_type] ?? '⚡'}</Text>
|
|
<View style={{ flex:1 }}>
|
|
<Text style={s.wsBannerT}>⚡ 실시간: {fmtTitle(wsEvents[0]?.event_type ?? '')}</Text>
|
|
<Text style={s.wsBannerM} numberOfLines={1}>{fmtMsg(wsEvents[0])}</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<ScrollView refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} />}>
|
|
{!loading && items.length===0 && (
|
|
<View style={s.empty}>
|
|
<Text style={{ fontSize:48 }}>🔔</Text>
|
|
<Text style={s.emptyT}>알림이 없습니다.</Text>
|
|
<Text style={s.emptyH}>SR 등록 또는 배포 실행 시{'\n'}알림이 표시됩니다.</Text>
|
|
</View>
|
|
)}
|
|
{items.map(n => (
|
|
<TouchableOpacity key={String(n.id)} style={[s.item, !n.is_read && s.unread]} onPress={() => markRead(n.id)}>
|
|
<View>
|
|
<Text style={s.icon}>{TYPE_ICON[n.type] ?? TYPE_ICON.DEFAULT}</Text>
|
|
{n.source==='ws' && <View style={s.wsPin} />}
|
|
</View>
|
|
<View style={{ flex:1 }}>
|
|
<Text style={[s.iT, !n.is_read && { fontWeight:'700' }]}>{n.title}</Text>
|
|
<Text style={s.iM} numberOfLines={2}>{n.message}</Text>
|
|
<View style={{ flexDirection:'row', gap:8, marginTop:4 }}>
|
|
<Text style={s.iTime}>{new Date(n.created_at).toLocaleString('ko-KR')}</Text>
|
|
{n.source==='ws' && <Text style={[s.iTime,{color:COLORS.accent}]}>⚡ 실시간</Text>}
|
|
</View>
|
|
</View>
|
|
{!n.is_read && <View style={s.readDot} />}
|
|
</TouchableOpacity>
|
|
))}
|
|
<View style={{ height:24 }} />
|
|
</ScrollView>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
header: { flexDirection:'row', alignItems:'center', justifyContent:'space-between',
|
|
padding:16, backgroundColor:'#fff', borderBottomWidth:1, borderBottomColor:COLORS.border },
|
|
title: { fontSize:17, fontWeight:'700', color:COLORS.text },
|
|
wsRow: { flexDirection:'row', alignItems:'center', gap:5, marginTop:3 },
|
|
dot: { width:7, height:7, borderRadius:4 },
|
|
wsLabel: { fontSize:11, fontWeight:'500' },
|
|
badge: { backgroundColor:COLORS.danger, borderRadius:10, paddingHorizontal:7, paddingVertical:2 },
|
|
badgeText: { color:'#fff', fontSize:11, fontWeight:'700' },
|
|
wsBanner: { flexDirection:'row', alignItems:'center', gap:10, padding:12,
|
|
backgroundColor:'#eff2ff', borderBottomWidth:1, borderBottomColor:'#c7d2fe' },
|
|
wsIcon: { fontSize:20 },
|
|
wsBannerT: { fontSize:12, fontWeight:'700', color:COLORS.accent },
|
|
wsBannerM: { fontSize:11, color:COLORS.muted },
|
|
item: { flexDirection:'row', backgroundColor:'#fff', padding:14,
|
|
borderBottomWidth:1, borderBottomColor:'#f1f5f9', gap:10, alignItems:'flex-start' },
|
|
unread: { backgroundColor:'#f8f9ff' },
|
|
icon: { fontSize:24, marginTop:2 },
|
|
wsPin: { position:'absolute', top:-2, right:-4, width:8, height:8, borderRadius:4, backgroundColor:COLORS.accent },
|
|
iT: { fontSize:14, color:COLORS.text },
|
|
iM: { fontSize:12, color:COLORS.muted, marginTop:3, lineHeight:17 },
|
|
iTime: { fontSize:11, color:COLORS.muted },
|
|
readDot: { width:8, height:8, borderRadius:4, backgroundColor:COLORS.accent, marginTop:6 },
|
|
empty: { alignItems:'center', marginTop:60, gap:10, paddingHorizontal:32 },
|
|
emptyT: { fontSize:15, fontWeight:'600', color:COLORS.muted },
|
|
emptyH: { fontSize:12, color:COLORS.muted, textAlign:'center', lineHeight:18 },
|
|
})
|