guardia-messenger/app/(tabs)/server_dashboard.tsx

190 lines
8.1 KiB
TypeScript

/**
* 서버 상태 대시보드 (#39)
*
* GET /api/servers/status
* 서버 카드: 서버명 + CPU% / MEM% / DISK% + 상태(online/warning/critical/offline)
* 자동 새로고침 30초.
*
* 보안: ip_addr, ssh_user, os_pw_enc 절대 표시 금지. 이름·상태·리소스 수치만.
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import {
View, Text, ScrollView, StyleSheet, RefreshControl, ActivityIndicator, TouchableOpacity,
} from 'react-native'
import { router } from 'expo-router'
import { COLORS } from '../../constants/Config'
import { getServerStatus } from '../../services/api'
type ServerState = 'online' | 'warning' | 'critical' | 'offline'
interface ServerCard {
id: string | number
name: string
state: ServerState
cpu: number
memory: number
disk: number
}
const STATE_META: Record<ServerState, { color: string; label: string; bg: string }> = {
online: { color: '#22c55e', label: '정상', bg: 'rgba(34,197,94,.1)' },
warning: { color: '#f59e0b', label: '경고', bg: 'rgba(245,158,11,.1)' },
critical: { color: '#ef4444', label: '위험', bg: 'rgba(239,68,68,.1)' },
offline: { color: '#94a3b8', label: '오프라인', bg: 'rgba(148,163,184,.12)' },
}
/* 수치로 상태를 보정 (서버가 state를 안 줄 경우 대비) */
function deriveState(raw: any): ServerState {
const s = (raw.state ?? raw.status ?? '').toString().toLowerCase()
if (['offline', 'down', 'unreachable'].includes(s)) return 'offline'
if (['critical', 'down', 'red'].includes(s)) return 'critical'
if (['warning', 'warn', 'yellow'].includes(s)) return 'warning'
if (['online', 'up', 'ok', 'green', 'healthy'].includes(s)) return 'online'
const max = Math.max(raw.cpu ?? 0, raw.memory ?? raw.mem ?? 0, raw.disk ?? 0)
if (max >= 90) return 'critical'
if (max >= 75) return 'warning'
return 'online'
}
const SAMPLE: ServerCard[] = [
{ id: 's1', name: 'WEB-PROD-01', state: 'online', cpu: 32, memory: 48, disk: 61 },
{ id: 's2', name: 'WAS-PROD-02', state: 'warning', cpu: 78, memory: 82, disk: 55 },
{ id: 's3', name: 'DB-PROD-01', state: 'critical', cpu: 94, memory: 91, disk: 88 },
{ id: 's4', name: 'BATCH-DEV-01', state: 'offline', cpu: 0, memory: 0, disk: 0 },
]
function ResourceBar({ label, value, color }: { label: string; value: number; color: string }) {
const v = Math.max(0, Math.min(100, value))
return (
<View style={s.barRow}>
<Text style={s.barLabel}>{label}</Text>
<View style={s.barTrack}>
<View style={[s.barFill, { width: `${v}%`, backgroundColor: color }]} />
</View>
<Text style={[s.barVal, { color }]}>{Math.round(v)}%</Text>
</View>
)
}
export default function ServerDashboardScreen() {
const [servers, setServers] = useState<ServerCard[]>([])
const [loading, setLoading] = useState(true)
const [refresh, setRefresh] = useState(false)
const [usedSample, setUsedSample] = useState(false)
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefresh(true) : undefined
try {
const res = await getServerStatus()
const raw: any[] = res.data?.servers ?? res.data?.items ?? res.data ?? []
const mapped: ServerCard[] = raw.map((r: any, idx: number) => ({
id: r.id ?? r.server_id ?? idx,
// 보안: 이름만 사용. ip/ssh/pw 필드는 무시.
name: r.name ?? r.hostname ?? r.server_name ?? `서버-${idx + 1}`,
state: deriveState(r),
cpu: Number(r.cpu ?? 0),
memory: Number(r.memory ?? r.mem ?? 0),
disk: Number(r.disk ?? 0),
}))
setServers(mapped)
setUsedSample(false)
} catch {
setServers(prev => prev.length ? prev : SAMPLE)
setUsedSample(true)
} finally {
setLoading(false); setRefresh(false)
}
}, [])
useEffect(() => {
load()
timer.current = setInterval(() => load(), 30000) // 30초 자동 새로고침
return () => { if (timer.current) clearInterval(timer.current) }
}, [load])
if (loading) return (
<View style={s.center}><ActivityIndicator color={COLORS.accent} size="large" /></View>
)
const counts = servers.reduce((acc, sv) => { acc[sv.state] = (acc[sv.state] ?? 0) + 1; return acc },
{} as Record<ServerState, number>)
return (
<ScrollView style={s.container}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}>
{/* 상태 요약 */}
<View style={s.summary}>
{(['online', 'warning', 'critical', 'offline'] as ServerState[]).map(st => (
<View key={st} style={s.summaryCard}>
<Text style={[s.summaryNum, { color: STATE_META[st].color }]}>{counts[st] ?? 0}</Text>
<Text style={s.summaryLabel}>{STATE_META[st].label}</Text>
</View>
))}
</View>
{usedSample && (
<Text style={s.sampleNote}> </Text>
)}
<Text style={s.sectionTitle}> ({servers.length})</Text>
{servers.map(sv => {
const meta = STATE_META[sv.state]
return (
<TouchableOpacity key={String(sv.id)} style={s.card}
onPress={() => router.push({ pathname: '/(tabs)/threshold_history', params: { server: sv.name } })}>
<View style={s.cardHeader}>
<View style={s.nameRow}>
<View style={[s.stateDot, { backgroundColor: meta.color }]} />
<Text style={s.cardName}>{sv.name}</Text>
</View>
<View style={[s.stateBadge, { backgroundColor: meta.bg }]}>
<Text style={[s.stateBadgeTxt, { color: meta.color }]}>{meta.label}</Text>
</View>
</View>
{sv.state === 'offline' ? (
<Text style={s.offlineTxt}> </Text>
) : (
<View style={s.bars}>
<ResourceBar label="CPU" value={sv.cpu} color="#3b82f6" />
<ResourceBar label="MEM" value={sv.memory} color="#22c55e" />
<ResourceBar label="DISK" value={sv.disk} color="#f59e0b" />
</View>
)}
</TouchableOpacity>
)
})}
<View style={{ height: 40 }} />
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.bg },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: COLORS.bg },
summary: { flexDirection: 'row', gap: 8, padding: 16 },
summaryCard: { flex: 1, backgroundColor: '#fff', borderRadius: 10, paddingVertical: 14, alignItems: 'center',
shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 4, elevation: 2 },
summaryNum: { fontSize: 22, fontWeight: '700' },
summaryLabel: { fontSize: 11, color: COLORS.muted, marginTop: 3 },
sampleNote: { fontSize: 11, color: COLORS.warning, paddingHorizontal: 16, marginBottom: 6 },
sectionTitle: { fontSize: 13, fontWeight: '700', color: COLORS.text, paddingHorizontal: 16, marginBottom: 8 },
card: { backgroundColor: '#fff', borderRadius: 12, padding: 14, marginHorizontal: 16, marginBottom: 10,
shadowColor: '#000', shadowOpacity: 0.04, shadowRadius: 4, elevation: 2 },
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
nameRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
stateDot: { width: 9, height: 9, borderRadius: 5 },
cardName: { fontSize: 14, fontWeight: '700', color: COLORS.text },
stateBadge: { paddingHorizontal: 9, paddingVertical: 3, borderRadius: 10 },
stateBadgeTxt:{ fontSize: 11, fontWeight: '700' },
bars: { gap: 7 },
barRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
barLabel: { width: 34, fontSize: 11, color: COLORS.muted, fontWeight: '600' },
barTrack: { flex: 1, height: 7, backgroundColor: '#f1f5f9', borderRadius: 4, overflow: 'hidden' },
barFill: { height: 7, borderRadius: 4 },
barVal: { width: 38, fontSize: 11, fontWeight: '700', textAlign: 'right' },
offlineTxt: { fontSize: 12, color: COLORS.muted, fontStyle: 'italic' },
})