Files
frontend/src/components/NodesStatus.vue
T
austnv 07229efac0
Build and Deploy Frontend via SSH / deploy (push) Failing after 35s
add NodesStatus.vue & v0.8.0
2026-06-21 17:02:02 +03:00

333 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section id="nodes" class="px-6 py-20 md:px-12 max-w-7xl mx-auto">
<div class="flex justify-between items-center mb-10 flex-wrap gap-4">
<div>
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white">
🌍 Статус серверов
</h2>
<p class="text-gray-500 dark:text-gray-400 mt-2">
{{ onlineCount }} из {{ nodes.length }} серверов онлайн
<span v-if="averagePing !== null" class="ml-4">
📶 Средний пинг: <span class="font-semibold text-blue-600 dark:text-blue-400">{{ averagePing }}ms</span>
</span>
</p>
</div>
<button
@click="refreshData"
:disabled="loading"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<span :class="{ 'animate-spin': loading }">🔄</span>
{{ loading ? 'Обновление...' : 'Обновить' }}
</button>
</div>
<!-- Состояние загрузки -->
<div v-if="loading && !nodes.length" class="flex justify-center py-20">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<!-- Ошибка -->
<div v-else-if="error" class="text-center text-red-500 py-12">
<p class="text-lg">{{ error }}</p>
<button @click="refreshData" class="mt-4 text-blue-600 dark:text-blue-400 hover:underline">
Попробовать снова
</button>
</div>
<!-- Сетка серверов -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="node in sortedNodes"
:key="node.name"
class="bg-white dark:bg-gray-900 rounded-xl p-5 border transition hover:shadow-lg"
:class="[
node.isOnline
? 'border-green-500/30 dark:border-green-500/20 hover:border-green-500'
: 'border-red-500/30 dark:border-red-500/20 opacity-60'
]"
>
<!-- Шапка -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<!-- Флаг из CDN -->
<img
v-if="node.country_code"
:src="`https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/4x3/${node.country_code.toLowerCase()}.svg`"
:alt="node.country_code.toLowerCase()"
class="h-5 w-auto rounded-sm"
/>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ node.name }}
</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ node.country_code }}
</span>
</div>
</div>
<span
class="text-xs px-2 py-1 rounded-full font-medium"
:class="node.isOnline
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'"
>
{{ node.isOnline ? '🟢 Онлайн' : '🔴 Офлайн' }}
</span>
</div>
<!-- IP и порт -->
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3 font-mono">
{{ node.ip }}:{{ node.port }}
</div>
<!-- Статистика -->
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-2 text-center">
<div class="text-gray-500 dark:text-gray-400 text-xs">Пользователи</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ node.users_online }}
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-2 text-center">
<div class="text-gray-500 dark:text-gray-400 text-xs">Uptime</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ formatUptime(node.uptime) }}
</div>
</div>
</div>
<!-- Пинг (если измерен) -->
<div v-if="node.ping !== undefined && node.ping !== null" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-500 dark:text-gray-400">📶 Пинг</span>
<span
class="font-semibold"
:class="{
'text-green-600 dark:text-green-400': node.ping < 50,
'text-yellow-600 dark:text-yellow-400': node.ping >= 50 && node.ping < 150,
'text-orange-600 dark:text-orange-400': node.ping >= 150 && node.ping < 300,
'text-red-600 dark:text-red-400': node.ping >= 300
}"
>
{{ node.ping }}ms
</span>
</div>
<!-- Прогресс-бар пинга -->
<div class="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full mt-1 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="{
'bg-green-500': node.ping < 50,
'bg-yellow-500': node.ping >= 50 && node.ping < 150,
'bg-orange-500': node.ping >= 150 && node.ping < 300,
'bg-red-500': node.ping >= 300
}"
:style="{ width: Math.min((node.ping / 300) * 100, 100) + '%' }"
></div>
</div>
</div>
<!-- Кнопка проверки пинга -->
<button
@click="pingNode(node)"
:disabled="node.pinging || !node.isOnline"
class="mt-3 w-full text-xs py-1.5 rounded-lg transition font-medium"
:class="[
node.isOnline
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 disabled:opacity-50'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed'
]"
>
{{ node.pinging ? '⏳ Измерение...' : 'Проверить пинг' }}
</button>
</div>
</div>
<!-- Футер с статистикой -->
<div
v-if="!loading && nodes.length"
class="mt-10 grid grid-cols-2 md:grid-cols-4 gap-4 text-center"
>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4">
<div class="text-2xl font-bold text-gray-900 dark:text-white">
{{ nodes.length }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Всего серверов</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ onlineCount }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Онлайн</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4">
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ offlineCount }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Офлайн</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{{ totalUsers }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Всего пользователей</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
// Состояние
const nodes = ref([])
const loading = ref(false)
const error = ref(null)
let updateInterval = null
// Вычисляемые свойства
const sortedNodes = computed(() => {
return [...nodes.value].sort((a, b) => {
// Сначала онлайн
if (a.isOnline !== b.isOnline) {
return a.isOnline ? -1 : 1
}
// Потом по имени
return a.name.localeCompare(b.name)
})
})
const onlineCount = computed(() => {
return nodes.value.filter(n => n.isOnline).length
})
const offlineCount = computed(() => {
return nodes.value.filter(n => !n.isOnline).length
})
const totalUsers = computed(() => {
return nodes.value.reduce((sum, n) => sum + (n.users_online || 0), 0)
})
const averagePing = computed(() => {
const pings = nodes.value
.filter(n => n.ping !== undefined && n.ping !== null)
.map(n => n.ping)
if (pings.length === 0) return null
return Math.round(pings.reduce((a, b) => a + b, 0) / pings.length)
})
// Методы
const formatUptime = (seconds) => {
if (!seconds) return '0м'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}д ${hours}ч`
if (hours > 0) return `${hours}ч ${minutes}м`
return `${minutes}м`
}
// Загрузка данных
const fetchNodes = async () => {
loading.value = true
error.value = null
try {
const response = await fetch('/api/v1/nodes')
if (!response.ok) {
throw new Error(`Ошибка: ${response.status}`)
}
const data = await response.json()
// Добавляем поле isOnline и инициализируем пинг
nodes.value = data.map(node => ({
...node,
isOnline: true, // Все ноды из бэкенда считаем онлайн
ping: undefined,
pinging: false
}))
} catch (err) {
console.error('Failed to load nodes:', err)
error.value = 'Не удалось загрузить статус серверов'
} finally {
loading.value = false
}
}
// Измерение пинга через WebSocket
const pingNode = async (node) => {
if (!node.isOnline || node.pinging) return
node.pinging = true
node.ping = undefined
const startTime = performance.now()
const wsUrl = `wss://${node.ip}:${node.port}`
try {
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
ws.close()
node.ping = null
node.pinging = false
}, 5000)
ws.onopen = () => {
const endTime = performance.now()
clearTimeout(timeout)
node.ping = Math.round(endTime - startTime)
node.pinging = false
ws.close()
}
ws.onerror = () => {
clearTimeout(timeout)
node.ping = null
node.pinging = false
}
} catch {
node.ping = null
node.pinging = false
}
}
// Измерение пинга для всех нод
const pingAllNodes = async () => {
const pingPromises = nodes.value.map(node => pingNode(node))
await Promise.allSettled(pingPromises)
}
// Обновление данных
const refreshData = async () => {
await fetchNodes()
// После загрузки данных измеряем пинг для онлайн нод
await pingAllNodes()
}
// Жизненный цикл
onMounted(() => {
refreshData()
// Автообновление каждые 30 секунд
updateInterval = setInterval(() => {
if (!loading.value) {
refreshData()
}
}, 30000)
})
onUnmounted(() => {
if (updateInterval) {
clearInterval(updateInterval)
}
})
</script>