guardia-messenger/components/ImpactPreview.tsx

119 lines
4.0 KiB
TypeScript

/**
* ImpactPreview (#27) — 장애 영향 서비스 예측 시각화
*
* GET /api/ai/impact/{server_id} 우선, 실패 시 /api/servers/{id}/dependencies.
* 영향받는 서비스 목록 + 연결 관계를 텍스트 기반 리스트로 표시.
*/
import { useState, useEffect } from 'react'
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'
import { COLORS, API_BASE } from '../constants/Config'
import { authFetch } from '../utils/auth'
interface ImpactNode {
name: string
type?: string // service / db / app ...
severity?: 'high' | 'medium' | 'low' | string
relation?: string // 'depends_on' 등
}
interface Props {
serverId: number | string
serverName?: string
}
const SEV_COLOR: Record<string, string> = {
high: COLORS.danger,
medium: COLORS.warning,
low: COLORS.success,
}
export function ImpactPreview({ serverId, serverName }: Props) {
const [nodes, setNodes] = useState<ImpactNode[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
const endpoints = [
`${API_BASE}/api/ai/impact/${serverId}`,
`${API_BASE}/api/servers/${serverId}/dependencies`,
]
for (const url of endpoints) {
try {
const res = await authFetch(url)
if (res.ok) {
const d = await res.json()
const raw = Array.isArray(d) ? d : d.impacted ?? d.services ?? d.dependencies ?? d.nodes ?? []
const list: ImpactNode[] = raw.map((x: any) => ({
name: x.name ?? x.service_name ?? x.hostname ?? String(x),
type: x.type ?? x.kind,
severity: x.severity ?? x.impact,
relation: x.relation ?? x.relationship,
}))
if (alive && list.length) {
setNodes(list)
setLoading(false)
return
}
}
} catch {
/* 다음 엔드포인트 시도 */
}
}
if (alive) {
setNodes([])
setLoading(false)
}
})()
return () => {
alive = false
}
}, [serverId])
return (
<View style={S.wrap}>
<Text style={S.title}>🌐 </Text>
{serverName ? <Text style={S.root}>: {serverName}</Text> : null}
{loading ? (
<ActivityIndicator color={COLORS.accent} style={{ marginTop: 8 }} />
) : nodes.length === 0 ? (
<Text style={S.empty}> .</Text>
) : (
<View style={S.list}>
{nodes.map((n, i) => (
<View key={i} style={S.node}>
<View style={[S.dot, { backgroundColor: SEV_COLOR[n.severity ?? 'low'] ?? COLORS.muted }]} />
<View style={{ flex: 1 }}>
<Text style={S.nodeName}>{n.name}</Text>
<Text style={S.nodeMeta}>
{n.type ? `${n.type} · ` : ''}
{n.relation ?? '연결됨'}
{n.severity ? ` · ${n.severity}` : ''}
</Text>
</View>
</View>
))}
<Text style={S.note}> {nodes.length} .</Text>
</View>
)}
</View>
)
}
export default ImpactPreview
const S = StyleSheet.create({
wrap: { backgroundColor: COLORS.card, borderRadius: 14, padding: 14, borderWidth: 1, borderColor: COLORS.border },
title: { fontSize: 15, fontWeight: '700', color: COLORS.text },
root: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
empty: { fontSize: 12, color: COLORS.muted, marginTop: 8 },
list: { marginTop: 10 },
node: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#f1f5f9', gap: 10 },
dot: { width: 10, height: 10, borderRadius: 5 },
nodeName: { fontSize: 13, fontWeight: '600', color: COLORS.text },
nodeMeta: { fontSize: 11, color: COLORS.muted, marginTop: 2 },
note: { fontSize: 11, color: COLORS.danger, marginTop: 8 },
})