190 lines
8.1 KiB
TypeScript
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' },
|
|
})
|