@@ -1,333 +1,91 @@
< 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 >
< section class = "px-6 py-12 max-w-7xl mx-auto" >
< h2 class = "text-2xl font-bold mb-6" > 🌍 Статус серверов < / h2 >
< div v-if = "loading" class="flex justify-center py-8 " >
< div class = "animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" > < / div >
< / 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 >
< p v-else-if = "error" class="text-red-500" > {{ error }} < / p >
<!-- Ошибка -- >
< 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-else class = "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3" >
< div
v-for = "node in sortedN odes"
v-for = "node in n odes"
:key = "node.name"
class = "bg-white dark:bg-gray-900 rounded-x l 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'
]"
class = "bg-white dark:bg-gray-900 rounded-lg p-3 border text-sm "
: class = "node.isOnline ? 'border-green-400' : 'border-red-400 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 ? '🟢 Онлайн' : '🔴 Офлайн' } }
< div class = "flex items-center gap-1.5 mb-1" >
< img
v-if = "node.country_code.toLowerCase()"
:src = "`https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/4x3/${node.country_code.toLowerCase()}.svg`"
class = "h-3.5 w-auto"
/ >
< span class = "font-semibold text-gray-800 dark:text-white" > { { node . name } } < / span >
< span class = "ml-auto text-[10px] px-1.5 py-0.5 rounded-full font-medium "
: class = "node.isOnline ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'" >
{ { node . isOnline ? 'online' : 'offline' } }
< / span >
< / div >
<!-- IP и порт -- >
< div class = "text-sm text-gray-500 dark:text-gray-400 mb-3 font-mono" >
{ { node . ip } } : { { node . port } }
< div class = "flex justify-between text-gray-500 dark:text-gray-400 text-xs" >
< span > 👤 { { node . users _online } } < / span >
< span > ⏱ { { formatUptime ( node . uptime ) } } < / span >
< span v-if = "node.ping !== undefined && node.ping !== null" class="font-mono font-semibold"
: class = "{
'text-green-600': node.ping < 50,
'text-yellow-600': node.ping >= 50 && node.ping < 150,
'text-orange-600': node.ping >= 150 && node.ping < 300,
'text-red-600': node.ping >= 300
}" >
{ { node . ping } } ms
< / span >
< span v-else class = "text-gray-300" > ⏳ < / span >
< / 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'
import { ref , onMounted , onUnmounted } from 'vue'
// Состояние
const nodes = ref ( [ ] )
const loading = ref ( false )
const loading = ref ( true )
const error = ref ( null )
let updateInterval = null
let interval = 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 formatUptime = ( s ) => {
if ( ! s ) return '0м'
const d = Math . floor ( s / 86400 ) , h = Math . floor ( ( s % 86400 ) / 3600 ) , m = Math . floor ( ( s % 3600 ) / 60 )
return d > 0 ? ` ${ d } д ${ h } ч ` : h > 0 ? ` ${ h } ч ${ m } м ` : ` ${ m } м `
}
// Загрузка данных
const pingNode = ( node ) => new Promise ( resolve => {
const start = performance . now ( )
const ws = new WebSocket ( ` wss:// ${ node . ip } : ${ node . port } ` )
const timer = setTimeout ( ( ) => { ws . close ( ) ; resolve ( null ) } , 3000 )
ws . onopen = ( ) => { clearTimeout ( timer ) ; resolve ( Math . round ( performance . now ( ) - start ) ) ; ws . close ( ) }
ws . onerror = ( ) => { clearTimeout ( timer ) ; resolve ( null ) }
} )
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 = 'Не удалось загрузить статус серверов'
const res = await fetch ( '/api/v1/nodes' )
if ( ! res . ok ) throw new Error ( )
const data = await res . json ( )
nodes . value = data . map ( n => ( { ... n , isOnline : true , ping : null } ) )
const pings = await Promise . all ( nodes . value . map ( n => pingNode ( n ) ) )
nodes . value . forEach ( ( n , i ) => n . ping = pings [ i ] )
} catch {
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 )
}
fetchNodes ( )
interval = setInterval ( fetchNodes , 30000 )
} )
onUnmounted ( ( ) => clearInterval ( interval ) )
< / script >