/** * 서버 상태 대시보드 (#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 = { 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 ( {label} {Math.round(v)}% ) } export default function ServerDashboardScreen() { const [servers, setServers] = useState([]) const [loading, setLoading] = useState(true) const [refresh, setRefresh] = useState(false) const [usedSample, setUsedSample] = useState(false) const timer = useRef | 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 ( ) const counts = servers.reduce((acc, sv) => { acc[sv.state] = (acc[sv.state] ?? 0) + 1; return acc }, {} as Record) return ( load(true)} tintColor={COLORS.accent} />}> {/* 상태 요약 */} {(['online', 'warning', 'critical', 'offline'] as ServerState[]).map(st => ( {counts[st] ?? 0} {STATE_META[st].label} ))} {usedSample && ( ※ 서버 연결 실패 — 샘플 데이터 표시 중 )} 서버 ({servers.length}) {servers.map(sv => { const meta = STATE_META[sv.state] return ( router.push({ pathname: '/(tabs)/threshold_history', params: { server: sv.name } })}> {sv.name} {meta.label} {sv.state === 'offline' ? ( 서버 응답 없음 ) : ( )} ) })} ) } 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' }, })