262 lines
7.2 KiB
Vue
262 lines
7.2 KiB
Vue
<template>
|
|
<div class="app-layout">
|
|
<SidebarNav />
|
|
|
|
<div class="main-content">
|
|
<header class="main-header">
|
|
<h1 class="page-title">📊 대시보드</h1>
|
|
<div class="header-info">
|
|
<span class="current-time">{{ currentTime }}</span>
|
|
<ThemeToggle />
|
|
</div>
|
|
</header>
|
|
|
|
<main class="main-body">
|
|
<DashboardControl
|
|
:interval="interval"
|
|
:auto-refresh="autoRefresh"
|
|
:last-fetch-time="lastFetchTime"
|
|
:fetch-state="fetchState"
|
|
@update:interval="updateInterval"
|
|
@update:auto-refresh="updateAutoRefresh"
|
|
@fetch-at="fetchAt"
|
|
@refresh="refresh"
|
|
/>
|
|
|
|
<div class="dashboard-layout">
|
|
<!-- 서버 현황 (좌측 90%) -->
|
|
<div class="server-section">
|
|
<ServerPortlet
|
|
v-if="serverDashboard"
|
|
:servers="serverDashboard.servers"
|
|
:summary="serverDashboard.summary"
|
|
@navigate="navigateTo"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 네트워크 상태 (우측 10%) -->
|
|
<div class="network-section">
|
|
<NetworkPortlet
|
|
type="pubnet"
|
|
title="Public"
|
|
icon="🌐"
|
|
:status="pubnetStatus"
|
|
/>
|
|
<NetworkPortlet
|
|
type="privnet"
|
|
title="Private"
|
|
icon="🔒"
|
|
:status="privnetStatus"
|
|
/>
|
|
<LocationStatsPortlet
|
|
:locations="locationStats"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<div class="connection-status" :class="{ connected: wsConnected }">
|
|
{{ wsConnected ? '🟢 연결됨' : '🔴 연결 끊김' }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
const router = useRouter()
|
|
|
|
// 초기값 상수
|
|
const DEFAULT_INTERVAL = 1
|
|
const DEFAULT_AUTO_REFRESH = true
|
|
const MIN_LOADING_TIME = 500
|
|
const SUCCESS_DISPLAY_TIME = 800
|
|
|
|
// 상태
|
|
const interval = ref(DEFAULT_INTERVAL)
|
|
const autoRefresh = ref(DEFAULT_AUTO_REFRESH)
|
|
const wsConnected = ref(false)
|
|
const currentTime = ref('')
|
|
const lastFetchTime = ref<Date | null>(null)
|
|
const fetchState = ref<'idle' | 'loading' | 'success'>('idle')
|
|
|
|
// 로딩 타이밍 관리
|
|
let loadingStartTime = 0
|
|
|
|
// 데이터
|
|
const pubnetStatus = ref<any>(null)
|
|
const privnetStatus = ref<any>(null)
|
|
const serverDashboard = ref<any>(null)
|
|
const locationStats = ref<any[]>([])
|
|
|
|
// WebSocket
|
|
let ws: WebSocket | null = null
|
|
let timeInterval: ReturnType<typeof window.setInterval> | null = null
|
|
|
|
async function fetchLocationStats() {
|
|
try {
|
|
const data = await $fetch('/api/server/location-stats')
|
|
locationStats.value = data as any[]
|
|
} catch (err) {
|
|
console.error('Failed to fetch location stats:', err)
|
|
}
|
|
}
|
|
|
|
function formatTime(date: Date): string {
|
|
const y = date.getFullYear()
|
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
const d = String(date.getDate()).padStart(2, '0')
|
|
const h = String(date.getHours()).padStart(2, '0')
|
|
const min = String(date.getMinutes()).padStart(2, '0')
|
|
const s = String(date.getSeconds()).padStart(2, '0')
|
|
return `${y}-${m}-${d} ${h}:${min}:${s}`
|
|
}
|
|
|
|
function updateCurrentTime() {
|
|
currentTime.value = formatTime(new Date())
|
|
}
|
|
|
|
function sendMessage(msg: object) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
console.log('[WS] Sending:', JSON.stringify(msg))
|
|
ws.send(JSON.stringify(msg))
|
|
}
|
|
}
|
|
|
|
function handleDataReceived(data: any) {
|
|
lastFetchTime.value = new Date()
|
|
|
|
if (data.pubnet) {
|
|
pubnetStatus.value = data.pubnet.status
|
|
}
|
|
|
|
if (data.privnet) {
|
|
privnetStatus.value = data.privnet.status
|
|
}
|
|
}
|
|
|
|
function finishLoading() {
|
|
fetchState.value = 'success'
|
|
setTimeout(() => {
|
|
fetchState.value = 'idle'
|
|
}, SUCCESS_DISPLAY_TIME)
|
|
}
|
|
|
|
function connectWebSocket() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
const wsUrl = `${protocol}//${window.location.host}/_ws`
|
|
|
|
ws = new WebSocket(wsUrl)
|
|
|
|
ws.onopen = () => {
|
|
console.log('[WS] Connected')
|
|
wsConnected.value = true
|
|
|
|
ws!.send(JSON.stringify({ type: 'set_interval', interval: DEFAULT_INTERVAL }))
|
|
ws!.send(JSON.stringify({ type: 'set_auto_refresh', enabled: DEFAULT_AUTO_REFRESH }))
|
|
ws!.send(JSON.stringify({ type: 'refresh' }))
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data)
|
|
|
|
if (msg.type === 'status' || msg.type === 'historical') {
|
|
handleDataReceived(msg.data)
|
|
|
|
if (fetchState.value === 'loading') {
|
|
const elapsed = Date.now() - loadingStartTime
|
|
const remaining = Math.max(0, MIN_LOADING_TIME - elapsed)
|
|
|
|
setTimeout(() => {
|
|
finishLoading()
|
|
}, remaining)
|
|
}
|
|
}
|
|
|
|
if (msg.type === 'server') {
|
|
serverDashboard.value = msg.data
|
|
fetchLocationStats()
|
|
}
|
|
} catch (err) {
|
|
console.error('[WS] Parse error:', err)
|
|
fetchState.value = 'idle'
|
|
}
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
console.log('[WS] Disconnected')
|
|
wsConnected.value = false
|
|
fetchState.value = 'idle'
|
|
|
|
setTimeout(() => {
|
|
if (autoRefresh.value) {
|
|
connectWebSocket()
|
|
}
|
|
}, 3000)
|
|
}
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('[WS] Error:', error)
|
|
fetchState.value = 'idle'
|
|
}
|
|
}
|
|
|
|
function updateInterval(min: number) {
|
|
interval.value = min
|
|
sendMessage({ type: 'set_interval', interval: min })
|
|
}
|
|
|
|
function updateAutoRefresh(enabled: boolean) {
|
|
autoRefresh.value = enabled
|
|
sendMessage({ type: 'set_auto_refresh', enabled })
|
|
}
|
|
|
|
function refresh() {
|
|
sendMessage({ type: 'refresh' })
|
|
}
|
|
|
|
function fetchAt(datetime: string) {
|
|
fetchState.value = 'loading'
|
|
loadingStartTime = Date.now()
|
|
sendMessage({ type: 'fetch_at', datetime })
|
|
}
|
|
|
|
function navigateTo(path: string) {
|
|
router.push(path)
|
|
}
|
|
|
|
onMounted(() => {
|
|
connectWebSocket()
|
|
updateCurrentTime()
|
|
fetchLocationStats()
|
|
timeInterval = window.setInterval(updateCurrentTime, 1000)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (ws) {
|
|
ws.close()
|
|
}
|
|
if (timeInterval) {
|
|
window.clearInterval(timeInterval)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
.app-layout { display: flex; height: 100vh; background: var(--bg-primary); }
|
|
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
.main-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); }
|
|
.page-title { margin: 0; font-size: 20px; font-weight: 600; color: var(--text-primary); }
|
|
.header-info { display: flex; align-items: center; gap: 16px; }
|
|
.current-time { font-size: 14px; color: var(--text-muted); font-family: monospace; }
|
|
.main-body { flex: 1; padding: 20px 24px; overflow-y: auto; }
|
|
|
|
.dashboard-layout { display: flex; gap: 16px; height: 100%; }
|
|
.server-section { flex: 9; min-width: 0; }
|
|
.network-section { flex: 1; min-width: 140px; max-width: 180px; display: flex; flex-direction: column; gap: 10px; }
|
|
|
|
.connection-status { position: fixed; bottom: 16px; right: 16px; padding: 8px 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 8px; font-size: 12px; color: var(--text-muted); }
|
|
.connection-status.connected { color: #16a34a; }
|
|
</style>
|