mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 15:57:53 +08:00
feat: 初版实现状态轮询
This commit is contained in:
@@ -10,6 +10,7 @@ const wsSftpV2 = require('./socket/sftp-v2')
|
||||
const wsDocker = require('./socket/docker')
|
||||
const wsClientInfo = require('./socket/clients')
|
||||
const wsOnekey = require('./socket/onekey')
|
||||
const wsServerStatus = require('./socket/server-status')
|
||||
const { throwError } = require('./utils/tools')
|
||||
|
||||
const httpServer = () => {
|
||||
@@ -32,6 +33,7 @@ function serverHandler(app, server) {
|
||||
wsDocker(server) // docker
|
||||
wsOnekey(server) // 一键指令
|
||||
wsClientInfo(server) // 客户端信息
|
||||
wsServerStatus(server) // 服务器状态监控
|
||||
app.context.throwError = throwError // 常用方法挂载全局ctx上
|
||||
app.use(compose(middlewares))
|
||||
// 捕获error.js模块抛出的服务错误
|
||||
|
||||
@@ -0,0 +1,934 @@
|
||||
const { Server } = require('socket.io')
|
||||
const { Client: SSHClient } = require('ssh2')
|
||||
const { verifyAuthSync } = require('../utils/verify-auth')
|
||||
const { isAllowedIp } = require('../utils/tools')
|
||||
const { createTerminal } = require('./terminal')
|
||||
|
||||
module.exports = (httpServer) => {
|
||||
const serverIo = new Server(httpServer, {
|
||||
path: '/server-status',
|
||||
cors: {
|
||||
origin: '*'
|
||||
}
|
||||
})
|
||||
|
||||
let connectionCount = 0
|
||||
|
||||
serverIo.on('connection', (socket) => {
|
||||
connectionCount++
|
||||
consola.success(`server-status websocket 已连接 - 当前连接数: ${ connectionCount }`)
|
||||
let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address
|
||||
if (!isAllowedIp(requestIP)) {
|
||||
socket.emit('ip_forbidden', 'IP地址不在白名单中')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
let targetSSHClient = null
|
||||
let jumpSshClients = []
|
||||
let monitorTimer = null
|
||||
let sendDataTimer = null
|
||||
let lastNetStats = null // 用于计算网络速率
|
||||
let serverError = false // 服务器错误标志
|
||||
let previousCpuStats = null // 用于计算CPU使用率
|
||||
let defaultNetInterface = null // 默认网络接口
|
||||
|
||||
// 系统信息缓存(只获取一次的信息)
|
||||
let staticSystemInfo = {
|
||||
cpuCount: null,
|
||||
cpuModel: null,
|
||||
osInfo: null
|
||||
}
|
||||
|
||||
// 服务器状态数据(外层维护,提升及时性)
|
||||
let statusData = {
|
||||
connect: false,
|
||||
cpuInfo: {},
|
||||
memInfo: {},
|
||||
swapInfo: {},
|
||||
driveInfo: {},
|
||||
netstatInfo: {},
|
||||
osInfo: {}
|
||||
}
|
||||
|
||||
socket.on('ws_server_status', async ({ hostId, token }) => {
|
||||
try {
|
||||
const { code } = await verifyAuthSync(token, requestIP)
|
||||
if (code !== 1) {
|
||||
socket.emit('token_verify_fail')
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
targetSSHClient = new SSHClient()
|
||||
let { jumpSshClients: statusJumpSshClients } = await createTerminal(hostId, socket, targetSSHClient, false)
|
||||
jumpSshClients.push(...statusJumpSshClients || [])
|
||||
|
||||
// 开始监控
|
||||
startMonitoring()
|
||||
} catch (error) {
|
||||
consola.error('ws_server_status 事件处理失败:', error.message)
|
||||
socket.emit('server_status_error', `连接失败: ${ error.message }`)
|
||||
}
|
||||
})
|
||||
|
||||
// 检查是否是关键的服务器错误
|
||||
const isServerCriticalError = (errorMessage) => {
|
||||
const criticalErrors = [
|
||||
'free: command not found', // free命令不存在
|
||||
'/proc/uptime: No such file or directory', // /proc/uptime文件不存在
|
||||
'/proc/net/dev: No such file or directory', // /proc/net/dev文件不存在
|
||||
'/proc/stat: No such file or directory', // /proc/stat文件不存在
|
||||
'/proc/cpuinfo: No such file or directory' // /proc/cpuinfo文件不存在
|
||||
]
|
||||
|
||||
return criticalErrors.some(error => errorMessage.includes(error))
|
||||
}
|
||||
|
||||
// 设置服务器错误状态并停止监控
|
||||
const setServerError = (reason) => {
|
||||
if (serverError) return // 已经设置过错误状态
|
||||
|
||||
serverError = true
|
||||
consola.error(`检测到服务器关键错误,停止监控: ${ reason }`)
|
||||
|
||||
// 停止监控定时器
|
||||
if (monitorTimer) {
|
||||
clearInterval(monitorTimer)
|
||||
monitorTimer = null
|
||||
}
|
||||
|
||||
// 发送错误状态给前端
|
||||
socket.emit('server_status_data', {
|
||||
connect: false,
|
||||
error: true,
|
||||
errorReason: reason,
|
||||
cpuInfo: {},
|
||||
memInfo: {},
|
||||
swapInfo: {},
|
||||
driveInfo: {},
|
||||
netstatInfo: {},
|
||||
osInfo: {}
|
||||
})
|
||||
}
|
||||
|
||||
// 执行SSH命令的通用函数(带重试机制)
|
||||
const executeCommand = (command, maxRetries = 2) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const attemptExecution = (retryCount) => {
|
||||
if (!targetSSHClient || !targetSSHClient._sock || !targetSSHClient._sock.writable) {
|
||||
reject(new Error('SSH 连接已断开'))
|
||||
return
|
||||
}
|
||||
|
||||
targetSSHClient.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
if (err.message.includes('Channel open failure') && retryCount < maxRetries) {
|
||||
// 通道打开失败,等待一段时间后重试
|
||||
setTimeout(() => {
|
||||
attemptExecution(retryCount + 1)
|
||||
}, 100 * (retryCount + 1)) // 递增延迟
|
||||
return
|
||||
}
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
stream
|
||||
.on('data', (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
.stderr.on('data', (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
.on('close', (code) => {
|
||||
if (code !== 0 && stderr) {
|
||||
reject(new Error(stderr))
|
||||
} else {
|
||||
resolve(stdout.trim())
|
||||
}
|
||||
})
|
||||
.on('error', (error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
attemptExecution(0)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取静态CPU信息(CPU核心数和型号,只获取一次)
|
||||
const getStaticCpuInfo = async () => {
|
||||
if (staticSystemInfo.cpuCount !== null && staticSystemInfo.cpuModel !== null) {
|
||||
return {
|
||||
cpuCount: staticSystemInfo.cpuCount,
|
||||
cpuModel: staticSystemInfo.cpuModel
|
||||
}
|
||||
}
|
||||
|
||||
let cpuCount = 0
|
||||
let cpuModel = 'Unknown'
|
||||
|
||||
// 获取CPU核心数
|
||||
try {
|
||||
const cpuCountResult = await executeCommand('nproc')
|
||||
cpuCount = parseInt(cpuCountResult) || 0
|
||||
|
||||
// 如果nproc失败,尝试其他方法
|
||||
if (cpuCount === 0) {
|
||||
const lscpuResult = await executeCommand('lscpu | grep "^CPU(s):" | head -1')
|
||||
const cpuCountMatch = lscpuResult.match(/CPU\(s\):\s*(\d+)/)
|
||||
cpuCount = cpuCountMatch ? parseInt(cpuCountMatch[1]) : 0
|
||||
}
|
||||
} catch (error) {
|
||||
consola.warn('获取CPU核心数失败:', error.message)
|
||||
}
|
||||
|
||||
// 获取CPU型号
|
||||
try {
|
||||
const cpuModelResult = await executeCommand('cat /proc/cpuinfo | grep "model name" | head -1')
|
||||
const cpuModelMatch = cpuModelResult.match(/model name\s*:\s*(.+)/)
|
||||
if (cpuModelMatch) {
|
||||
cpuModel = cpuModelMatch[1].trim()
|
||||
} else {
|
||||
// 如果/proc/cpuinfo失败,尝试lscpu
|
||||
const lscpuResult = await executeCommand('lscpu | grep "Model name:" | head -1')
|
||||
const lscpuModelMatch = lscpuResult.match(/Model name:\s*(.+)/)
|
||||
cpuModel = lscpuModelMatch ? lscpuModelMatch[1].trim() : 'Unknown'
|
||||
}
|
||||
} catch (error) {
|
||||
consola.warn('获取CPU型号失败:', error.message)
|
||||
|
||||
// 检查是否是关键错误
|
||||
if (isServerCriticalError(error.message)) {
|
||||
setServerError(`获取CPU型号失败: ${ error.message }`)
|
||||
return { cpuCount: 0, cpuModel: 'Unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
staticSystemInfo.cpuCount = cpuCount
|
||||
staticSystemInfo.cpuModel = cpuModel
|
||||
|
||||
return { cpuCount, cpuModel }
|
||||
}
|
||||
|
||||
// 解析/proc/stat输出
|
||||
const parseProcStat = (procStatOutput) => {
|
||||
try {
|
||||
const firstLine = procStatOutput.split('\n')[0]
|
||||
const values = firstLine.split(' ').slice(2).map(Number)
|
||||
const idle = values[3] || 0
|
||||
const total = values.reduce((a, b) => a + b, 0)
|
||||
return { idle, total }
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取CPU信息(优化的CPU使用率计算)
|
||||
const getCpuInfo = async () => {
|
||||
let cpuUsage = 0
|
||||
|
||||
try {
|
||||
const procStatOutput = await executeCommand('cat /proc/stat')
|
||||
const currentCpuStats = parseProcStat(procStatOutput)
|
||||
const now = Date.now()
|
||||
|
||||
if (currentCpuStats) {
|
||||
if (previousCpuStats && previousCpuStats.timestamp < now) {
|
||||
const totalDiff = currentCpuStats.total - previousCpuStats.total
|
||||
const idleDiff = currentCpuStats.idle - previousCpuStats.idle
|
||||
const timeDiffMs = now - previousCpuStats.timestamp
|
||||
|
||||
// 确保时间差>500ms以获得更准确的计算,避免除零错误
|
||||
if (totalDiff > 0 && timeDiffMs > 500) {
|
||||
const usageRatio = 1.0 - (idleDiff / totalDiff)
|
||||
// 限制在0-100范围内
|
||||
cpuUsage = Math.max(0, Math.min(100, usageRatio * 100))
|
||||
} else {
|
||||
// 时间间隔太短或没有变化,保持上次的值或设为0
|
||||
cpuUsage = 0
|
||||
}
|
||||
} else {
|
||||
// 第一次运行,无法计算使用率
|
||||
cpuUsage = 0
|
||||
}
|
||||
|
||||
// 存储当前统计信息供下次使用
|
||||
previousCpuStats = { ...currentCpuStats, timestamp: now }
|
||||
}
|
||||
} catch (error) {
|
||||
consola.warn('获取CPU使用率失败:', error.message)
|
||||
|
||||
// 检查是否是关键错误
|
||||
if (isServerCriticalError(error.message)) {
|
||||
setServerError(`获取CPU使用率失败: ${ error.message }`)
|
||||
return { cpuUsage: 0, cpuCount: 0, cpuModel: 'Unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取静态CPU信息
|
||||
const { cpuCount, cpuModel } = await getStaticCpuInfo()
|
||||
|
||||
// 获取负载平均值
|
||||
let loadAvg = [0, 0, 0]
|
||||
try {
|
||||
const uptimeOutput = await executeCommand('uptime')
|
||||
const match = uptimeOutput.match(/load average(?:s)?:\s*([\d.]+)[, ]?\s*([\d.]+)[, ]?\s*([\d.]+)/)
|
||||
if (match) {
|
||||
loadAvg = [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3])]
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理负载平均值获取失败
|
||||
}
|
||||
|
||||
return {
|
||||
cpuUsage: parseFloat(cpuUsage.toFixed(2)),
|
||||
cpuCount,
|
||||
cpuModel,
|
||||
loadAvg
|
||||
}
|
||||
}
|
||||
|
||||
// 获取内存和交换空间信息(统一处理,支持BusyBox)
|
||||
const getMemoryInfo = async () => {
|
||||
const defaultReturn = {
|
||||
memInfo: { totalMemMb: 0, usedMemMb: 0, freeMemMb: 0, usedMemPercentage: 0, freeMemPercentage: 0 },
|
||||
swapInfo: { swapTotal: 0, swapUsed: 0, swapFree: 0, swapPercentage: '0' }
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查是否为BusyBox环境
|
||||
let freeCommand = 'free -m'
|
||||
let isBusyBox = false
|
||||
|
||||
try {
|
||||
const busyboxCheck = await executeCommand('busybox --help')
|
||||
if (busyboxCheck.includes('BusyBox')) {
|
||||
freeCommand = 'free'
|
||||
isBusyBox = true
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果检查失败,默认使用 free -m
|
||||
}
|
||||
|
||||
const freeOutput = await executeCommand(freeCommand)
|
||||
const lines = freeOutput.split('\n')
|
||||
|
||||
// 使用更鲁棒的方式查找内存和交换空间行
|
||||
const memLine = lines.find(line => line.trim().startsWith('Mem:'))
|
||||
const swapLine = lines.find(line => line.trim().startsWith('Swap:'))
|
||||
|
||||
let memInfo = defaultReturn.memInfo
|
||||
let swapInfo = defaultReturn.swapInfo
|
||||
|
||||
// 处理内存信息
|
||||
if (memLine) {
|
||||
const parts = memLine.trim().split(/\s+/)
|
||||
if (parts.length >= 3) {
|
||||
let totalVal = parseInt(parts[1], 10)
|
||||
let usedVal = parseInt(parts[2], 10)
|
||||
let freeVal = parts[3] ? parseInt(parts[3], 10) : (totalVal - usedVal)
|
||||
|
||||
// BusyBox环境下需要从KB转换为MB
|
||||
if (isBusyBox) {
|
||||
if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024)
|
||||
if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024)
|
||||
if (!isNaN(freeVal)) freeVal = Math.round(freeVal / 1024)
|
||||
}
|
||||
|
||||
if (!isNaN(totalVal) && !isNaN(usedVal)) {
|
||||
const usedMemPercentage = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(2)) : 0
|
||||
const freeMemPercentage = totalVal > 0 ? parseFloat(((freeVal / totalVal) * 100).toFixed(2)) : 0
|
||||
|
||||
memInfo = {
|
||||
totalMemMb: totalVal,
|
||||
usedMemMb: usedVal,
|
||||
freeMemMb: freeVal,
|
||||
usedMemPercentage,
|
||||
freeMemPercentage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理交换空间信息
|
||||
if (swapLine) {
|
||||
const parts = swapLine.trim().split(/\s+/)
|
||||
if (parts.length >= 3) {
|
||||
let totalVal = parseInt(parts[1], 10)
|
||||
let usedVal = parseInt(parts[2], 10)
|
||||
let freeVal = parts[3] ? parseInt(parts[3], 10) : (totalVal - usedVal)
|
||||
|
||||
// BusyBox环境下需要从KB转换为MB
|
||||
if (isBusyBox) {
|
||||
if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024)
|
||||
if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024)
|
||||
if (!isNaN(freeVal)) freeVal = Math.round(freeVal / 1024)
|
||||
}
|
||||
|
||||
if (!isNaN(totalVal) && !isNaN(usedVal)) {
|
||||
const swapPercentage = totalVal > 0 ? ((usedVal / totalVal) * 100).toFixed(1) : '0'
|
||||
|
||||
swapInfo = {
|
||||
swapTotal: totalVal,
|
||||
swapUsed: usedVal,
|
||||
swapFree: freeVal,
|
||||
swapPercentage
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有交换空间
|
||||
swapInfo = { swapTotal: 0, swapUsed: 0, swapFree: 0, swapPercentage: '0' }
|
||||
}
|
||||
|
||||
return { memInfo, swapInfo }
|
||||
|
||||
} catch (error) {
|
||||
consola.error('获取内存信息失败:', error.message)
|
||||
|
||||
// 检查是否是关键错误
|
||||
if (isServerCriticalError(error.message)) {
|
||||
setServerError(`获取内存信息失败: ${ error.message }`)
|
||||
}
|
||||
|
||||
return defaultReturn
|
||||
}
|
||||
}
|
||||
|
||||
// 获取内存信息(兼容性包装函数)
|
||||
const getMemInfo = async () => {
|
||||
const { memInfo } = await getMemoryInfo()
|
||||
return memInfo
|
||||
}
|
||||
|
||||
// 获取Swap信息(兼容性包装函数)
|
||||
const getSwapInfo = async () => {
|
||||
const { swapInfo } = await getMemoryInfo()
|
||||
return swapInfo
|
||||
}
|
||||
|
||||
// 获取磁盘信息
|
||||
const getDriveInfo = async () => {
|
||||
try {
|
||||
const driveInfo = await executeCommand('df -kP / | tail -1')
|
||||
const driveLine = driveInfo.split(/\s+/)
|
||||
|
||||
const totalKb = parseInt(driveLine[1])
|
||||
const usedKb = parseInt(driveLine[2])
|
||||
const freeKb = parseInt(driveLine[3])
|
||||
const usedPercentage = parseFloat(driveLine[4].replace('%', ''))
|
||||
|
||||
return {
|
||||
totalGb: (totalKb / 1024 / 1024).toFixed(1),
|
||||
usedGb: (usedKb / 1024 / 1024).toFixed(1),
|
||||
freeGb: (freeKb / 1024 / 1024).toFixed(1),
|
||||
usedPercentage: usedPercentage.toFixed(1),
|
||||
freePercentage: (100 - usedPercentage).toFixed(1)
|
||||
}
|
||||
} catch (error) {
|
||||
consola.error('获取磁盘信息失败:', error.message)
|
||||
return { totalGb: '0', usedGb: '0', freeGb: '0', usedPercentage: '0', freePercentage: '0' }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取默认网络接口
|
||||
const getDefaultInterface = async () => {
|
||||
if (defaultNetInterface) {
|
||||
return defaultNetInterface
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试通过路由表获取默认接口
|
||||
const routeOutput = await executeCommand('ip route get 1')
|
||||
const match = routeOutput.match(/dev\s+(\w+)/)
|
||||
if (match) {
|
||||
defaultNetInterface = match[1]
|
||||
return defaultNetInterface
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理,继续尝试其他方法
|
||||
}
|
||||
|
||||
try {
|
||||
// 备用方法:获取第一个非lo接口
|
||||
const netDev = await executeCommand('cat /proc/net/dev')
|
||||
const lines = netDev.split('\n').slice(2)
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (parts.length >= 9 && parts[0]) {
|
||||
const interfaceName = parts[0].replace(':', '')
|
||||
if (interfaceName !== 'lo') {
|
||||
defaultNetInterface = interfaceName
|
||||
return defaultNetInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 解析网络设备统计信息
|
||||
const parseNetworkStats = async () => {
|
||||
try {
|
||||
const netDev = await executeCommand('cat /proc/net/dev')
|
||||
const lines = netDev.split('\n').slice(2)
|
||||
const stats = {}
|
||||
|
||||
lines.forEach(line => {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (parts.length >= 9 && parts[0]) {
|
||||
const interfaceName = parts[0].replace(':', '')
|
||||
const rxBytes = parseInt(parts[1]) || 0
|
||||
const txBytes = parseInt(parts[9]) || 0
|
||||
stats[interfaceName] = { rxBytes, txBytes }
|
||||
}
|
||||
})
|
||||
|
||||
return stats
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取网络信息(优化版)
|
||||
const getNetstatInfo = async () => {
|
||||
try {
|
||||
const currentStats = await parseNetworkStats()
|
||||
if (!currentStats) {
|
||||
return { total: { inputMb: 0, outputMb: 0 } }
|
||||
}
|
||||
|
||||
const defaultInterface = await getDefaultInterface()
|
||||
const timestamp = Date.now()
|
||||
let netstatInfo = {}
|
||||
|
||||
// 计算总流量(所有接口)
|
||||
let totalInput = 0
|
||||
let totalOutput = 0
|
||||
Object.values(currentStats).forEach(stat => {
|
||||
totalInput += stat.rxBytes
|
||||
totalOutput += stat.txBytes
|
||||
})
|
||||
|
||||
if (lastNetStats && lastNetStats.timestamp < timestamp) {
|
||||
const timeDiffSeconds = (timestamp - lastNetStats.timestamp) / 1000
|
||||
|
||||
if (timeDiffSeconds > 0.1) {
|
||||
// 总流量速率
|
||||
netstatInfo.total = {
|
||||
inputMb: ((totalInput - lastNetStats.totalInput) / timeDiffSeconds / 1024 / 1024).toFixed(3),
|
||||
outputMb: ((totalOutput - lastNetStats.totalOutput) / timeDiffSeconds / 1024 / 1024).toFixed(3)
|
||||
}
|
||||
|
||||
// 默认接口的速率
|
||||
if (defaultInterface && currentStats[defaultInterface] && lastNetStats.interfaces[defaultInterface]) {
|
||||
const currentRx = currentStats[defaultInterface].rxBytes
|
||||
const currentTx = currentStats[defaultInterface].txBytes
|
||||
const lastRx = lastNetStats.interfaces[defaultInterface].rxBytes
|
||||
const lastTx = lastNetStats.interfaces[defaultInterface].txBytes
|
||||
|
||||
netstatInfo.default = {
|
||||
interface: defaultInterface,
|
||||
inputMb: ((currentRx - lastRx) / timeDiffSeconds / 1024 / 1024).toFixed(3),
|
||||
outputMb: ((currentTx - lastTx) / timeDiffSeconds / 1024 / 1024).toFixed(3)
|
||||
}
|
||||
}
|
||||
|
||||
// 各个接口的速率
|
||||
Object.keys(currentStats).forEach(interfaceName => {
|
||||
if (lastNetStats.interfaces[interfaceName]) {
|
||||
const currentInterface = currentStats[interfaceName]
|
||||
const lastInterface = lastNetStats.interfaces[interfaceName]
|
||||
|
||||
netstatInfo[interfaceName] = {
|
||||
inputMb: ((currentInterface.rxBytes - lastInterface.rxBytes) / timeDiffSeconds / 1024 / 1024).toFixed(3),
|
||||
outputMb: ((currentInterface.txBytes - lastInterface.txBytes) / timeDiffSeconds / 1024 / 1024).toFixed(3)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 时间间隔太短,设为0
|
||||
netstatInfo.total = { inputMb: '0.000', outputMb: '0.000' }
|
||||
if (defaultInterface) {
|
||||
netstatInfo.default = {
|
||||
interface: defaultInterface,
|
||||
inputMb: '0.000',
|
||||
outputMb: '0.000'
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 第一次运行,初始化为0
|
||||
netstatInfo.total = { inputMb: '0.000', outputMb: '0.000' }
|
||||
if (defaultInterface) {
|
||||
netstatInfo.default = {
|
||||
interface: defaultInterface,
|
||||
inputMb: '0.000',
|
||||
outputMb: '0.000'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前状态
|
||||
lastNetStats = {
|
||||
totalInput,
|
||||
totalOutput,
|
||||
interfaces: currentStats,
|
||||
timestamp
|
||||
}
|
||||
|
||||
return netstatInfo
|
||||
} catch (error) {
|
||||
consola.error('获取网络信息失败:', error.message)
|
||||
|
||||
// 检查是否是关键错误
|
||||
if (isServerCriticalError(error.message)) {
|
||||
setServerError(`获取网络信息失败: ${ error.message }`)
|
||||
return { total: { inputMb: '0.000', outputMb: '0.000' } }
|
||||
}
|
||||
|
||||
return { total: { inputMb: '0.000', outputMb: '0.000' } }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取静态系统信息(只获取一次的信息)
|
||||
const getStaticOsInfo = async () => {
|
||||
if (staticSystemInfo.osInfo !== null) {
|
||||
return staticSystemInfo.osInfo
|
||||
}
|
||||
|
||||
const defaultStaticInfo = {
|
||||
hostname: 'Unknown',
|
||||
type: 'Linux',
|
||||
platform: 'linux',
|
||||
release: 'Unknown',
|
||||
arch: 'Unknown'
|
||||
}
|
||||
|
||||
try {
|
||||
// 串行执行命令避免并发问题
|
||||
let hostname = 'Unknown'
|
||||
try {
|
||||
hostname = await executeCommand('hostname') || 'Unknown'
|
||||
} catch (e) {
|
||||
consola.warn('获取hostname失败:', e.message)
|
||||
}
|
||||
|
||||
// 通过读取 /etc/os-release 文件获取操作系统信息
|
||||
let type = 'Linux'
|
||||
let release = 'Unknown'
|
||||
let arch = 'Unknown'
|
||||
|
||||
try {
|
||||
// 读取 /etc/os-release 文件获取系统信息
|
||||
const osReleaseContent = await executeCommand('cat /etc/os-release')
|
||||
const osReleaseLines = osReleaseContent.split('\n')
|
||||
|
||||
// 解析系统名称和版本
|
||||
osReleaseLines.forEach(line => {
|
||||
if (line.startsWith('NAME=')) {
|
||||
type = line.replace('NAME=', '').replace(/"/g, '') || 'Linux'
|
||||
} else if (line.startsWith('VERSION=')) {
|
||||
release = line.replace('VERSION=', '').replace(/"/g, '') || 'Unknown'
|
||||
} else if (line.startsWith('PRETTY_NAME=')) {
|
||||
// 如果有PRETTY_NAME,可以作为更友好的显示名称
|
||||
const prettyName = line.replace('PRETTY_NAME=', '').replace(/"/g, '')
|
||||
if (prettyName) {
|
||||
type = prettyName.split(' ')[0] // 取第一个词作为系统类型
|
||||
// 提取版本信息
|
||||
const versionMatch = prettyName.match(/(\d+\.?\d*\.?\d*)/)
|
||||
if (versionMatch) {
|
||||
release = versionMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
consola.warn('读取 /etc/os-release 失败,尝试使用 uname 命令:', e.message)
|
||||
// 如果读取 /etc/os-release 失败,回退到 uname 命令
|
||||
try {
|
||||
type = await executeCommand('uname -s') || 'Linux'
|
||||
} catch (e2) {
|
||||
consola.warn('获取系统类型失败:', e2.message)
|
||||
}
|
||||
|
||||
try {
|
||||
release = await executeCommand('uname -r') || 'Unknown'
|
||||
} catch (e2) {
|
||||
consola.warn('获取系统版本失败:', e2.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统架构
|
||||
try {
|
||||
arch = await executeCommand('uname -m') || 'Unknown'
|
||||
} catch (e) {
|
||||
consola.warn('获取系统架构失败:', e.message)
|
||||
}
|
||||
|
||||
const staticInfo = {
|
||||
hostname: hostname.trim(),
|
||||
type: type.trim(),
|
||||
platform: 'linux',
|
||||
release: release.trim(),
|
||||
arch: arch.trim()
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
staticSystemInfo.osInfo = staticInfo
|
||||
return staticInfo
|
||||
} catch (error) {
|
||||
consola.error('获取静态系统信息失败:', error.message)
|
||||
staticSystemInfo.osInfo = defaultStaticInfo
|
||||
return defaultStaticInfo
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统信息(结合静态信息和动态信息)
|
||||
const getOsInfo = async () => {
|
||||
try {
|
||||
// 获取静态信息
|
||||
const staticInfo = await getStaticOsInfo()
|
||||
|
||||
// 获取动态信息:系统运行时间
|
||||
let uptime = 0
|
||||
try {
|
||||
const uptimeStr = await executeCommand('cat /proc/uptime | cut -d" " -f1') || '0'
|
||||
uptime = parseFloat(uptimeStr) || 0
|
||||
} catch (e) {
|
||||
consola.warn('获取系统运行时间失败:', e.message)
|
||||
|
||||
// 检查是否是关键错误
|
||||
if (isServerCriticalError(e.message)) {
|
||||
setServerError(`获取系统运行时间失败: ${ e.message }`)
|
||||
return { ...staticInfo, uptime: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...staticInfo,
|
||||
uptime
|
||||
}
|
||||
} catch (error) {
|
||||
consola.error('获取系统信息失败:', error.message)
|
||||
return {
|
||||
hostname: 'Unknown',
|
||||
type: 'Linux',
|
||||
platform: 'linux',
|
||||
release: 'Unknown',
|
||||
arch: 'Unknown',
|
||||
uptime: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新CPU信息到全局statusData
|
||||
const updateCpuInfo = async () => {
|
||||
try {
|
||||
const cpuInfo = await getCpuInfo()
|
||||
statusData.cpuInfo = cpuInfo
|
||||
statusData.connect = true // 标记连接状态
|
||||
} catch (error) {
|
||||
consola.error('更新CPU信息失败:', error.message)
|
||||
statusData.cpuInfo = {}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新内存信息到全局statusData
|
||||
const updateMemoryInfo = async () => {
|
||||
try {
|
||||
const { memInfo, swapInfo } = await getMemoryInfo()
|
||||
statusData.memInfo = memInfo
|
||||
statusData.swapInfo = swapInfo
|
||||
statusData.connect = true
|
||||
} catch (error) {
|
||||
consola.error('更新内存信息失败:', error.message)
|
||||
statusData.memInfo = {}
|
||||
statusData.swapInfo = {}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新磁盘信息到全局statusData
|
||||
const updateDriveInfo = async () => {
|
||||
try {
|
||||
const driveInfo = await getDriveInfo()
|
||||
statusData.driveInfo = driveInfo
|
||||
statusData.connect = true
|
||||
} catch (error) {
|
||||
consola.error('更新磁盘信息失败:', error.message)
|
||||
statusData.driveInfo = {}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新网络信息到全局statusData
|
||||
const updateNetworkInfo = async () => {
|
||||
try {
|
||||
const netstatInfo = await getNetstatInfo()
|
||||
statusData.netstatInfo = netstatInfo
|
||||
statusData.connect = true
|
||||
} catch (error) {
|
||||
consola.error('更新网络信息失败:', error.message)
|
||||
statusData.netstatInfo = {}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新系统信息到全局statusData
|
||||
const updateOsInfo = async () => {
|
||||
try {
|
||||
const osInfo = await getOsInfo()
|
||||
statusData.osInfo = osInfo
|
||||
statusData.connect = true
|
||||
} catch (error) {
|
||||
consola.error('更新系统信息失败:', error.message)
|
||||
statusData.osInfo = {}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新所有服务器状态信息(改为增量更新模式)
|
||||
const updateServerStatus = async () => {
|
||||
try {
|
||||
// 并行执行一些不冲突的数据获取,每完成一个就立即更新statusData
|
||||
const updateTasks = [
|
||||
updateCpuInfo(),
|
||||
updateMemoryInfo(),
|
||||
updateDriveInfo(),
|
||||
updateNetworkInfo(),
|
||||
updateOsInfo()
|
||||
]
|
||||
|
||||
// 使用Promise.allSettled确保即使某个更新失败,其他更新仍能继续
|
||||
const results = await Promise.allSettled(updateTasks)
|
||||
|
||||
// 检查是否有任何成功的更新
|
||||
const hasSuccessfulUpdate = results.some(result => result.status === 'fulfilled')
|
||||
if (!hasSuccessfulUpdate) {
|
||||
// 如果所有更新都失败,标记为断开连接
|
||||
statusData.connect = false
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
consola.error('更新服务器状态过程中出错:', error.message)
|
||||
statusData.connect = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始监控(解耦数据收集和数据发送,提升及时性)
|
||||
let isCollecting = false
|
||||
const startMonitoring = () => {
|
||||
// 数据收集函数 - 每2秒执行一次
|
||||
const collectData = async () => {
|
||||
try {
|
||||
if (isCollecting) return
|
||||
isCollecting = true
|
||||
// 如果已经检测到服务器错误,停止监控
|
||||
if (serverError) {
|
||||
if (monitorTimer) {
|
||||
clearInterval(monitorTimer)
|
||||
monitorTimer = null
|
||||
}
|
||||
if (sendDataTimer) {
|
||||
clearInterval(sendDataTimer)
|
||||
sendDataTimer = null
|
||||
}
|
||||
consola.info('服务器存在关键错误,已停止监控')
|
||||
return
|
||||
}
|
||||
|
||||
// 更新全局statusData
|
||||
await updateServerStatus()
|
||||
} catch (error) {
|
||||
consola.error('数据收集过程中出错:', error.message)
|
||||
} finally {
|
||||
isCollecting = false
|
||||
}
|
||||
}
|
||||
|
||||
// 数据发送函数 - 每1秒执行一次
|
||||
const sendData = () => {
|
||||
try {
|
||||
// 如果检测到服务器错误,停止发送
|
||||
if (serverError) {
|
||||
if (sendDataTimer) {
|
||||
clearInterval(sendDataTimer)
|
||||
sendDataTimer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 发送当前statusData给前端
|
||||
socket.emit('server_status_data', statusData)
|
||||
} catch (error) {
|
||||
consola.error('数据发送过程中出错:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 立即执行一次数据收集
|
||||
collectData()
|
||||
|
||||
// 启动定时器(如果没有错误)
|
||||
if (!serverError) {
|
||||
// 每2秒收集一次数据
|
||||
monitorTimer = setInterval(collectData, 3000)
|
||||
// 每1秒发送一次数据(1秒后开始,避免与首次收集冲突)
|
||||
if (!serverError) {
|
||||
sendDataTimer = setInterval(sendData, 1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
connectionCount--
|
||||
|
||||
// 清理所有定时器
|
||||
if (monitorTimer) {
|
||||
clearInterval(monitorTimer)
|
||||
monitorTimer = null
|
||||
}
|
||||
if (sendDataTimer) {
|
||||
clearInterval(sendDataTimer)
|
||||
sendDataTimer = null
|
||||
}
|
||||
|
||||
targetSSHClient && targetSSHClient.end()
|
||||
jumpSshClients?.forEach(sshClient => sshClient && sshClient.end())
|
||||
|
||||
// 清理所有状态变量
|
||||
targetSSHClient = null
|
||||
jumpSshClients = null
|
||||
lastNetStats = null
|
||||
previousCpuStats = null
|
||||
defaultNetInterface = null
|
||||
serverError = false
|
||||
|
||||
// 清理系统信息缓存
|
||||
staticSystemInfo = {
|
||||
cpuCount: null,
|
||||
cpuModel: null,
|
||||
osInfo: null
|
||||
}
|
||||
|
||||
// 重置状态数据
|
||||
statusData = {
|
||||
connect: false,
|
||||
cpuInfo: {},
|
||||
memInfo: {},
|
||||
swapInfo: {},
|
||||
driveInfo: {},
|
||||
netstatInfo: {},
|
||||
osInfo: {}
|
||||
}
|
||||
|
||||
consola.info(`server-status websocket 连接断开: ${ reason } - 当前连接数: ${ connectionCount }`)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"ant-design-x-vue": "^1.0.7",
|
||||
"axios": "^1.8.4",
|
||||
"chart.js": "^4.5.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
|
||||
@@ -59,12 +59,10 @@ html.dark {
|
||||
}
|
||||
}
|
||||
}
|
||||
.info_box {
|
||||
border-right: 1px solid #454242;
|
||||
.el-progress-bar__innerText {
|
||||
span {
|
||||
color: #fff !important;
|
||||
}
|
||||
.el-progress-bar__innerText {
|
||||
span {
|
||||
color: #ffffff !important; // 强制白色文字,确保在各种背景下都能看清
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); // 添加文字阴影增强可读性
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,18 @@ export default {
|
||||
value = Number(value)
|
||||
return isNaN(value) ? '--' : value.toFixed(count)
|
||||
},
|
||||
formatTime(second = 0) {
|
||||
formatTime(second = 0, target = 'day') {
|
||||
let day = Math.floor(second / 60 / 60 / 24)
|
||||
let hour = Math.floor(second / 60 / 60 % 24)
|
||||
let minute = Math.floor(second / 60 % 60)
|
||||
return `${ day }天${ hour }时${ minute }分`
|
||||
if (target === 'day') {
|
||||
return `${ day }天`
|
||||
} else if (target === 'hour') {
|
||||
return `${ day }天${ hour }时`
|
||||
} else if (target === 'minute') {
|
||||
return `${ day }天${ hour }时${ minute }分`
|
||||
}
|
||||
return `${ day }天${ hour }时${ minute }分${ second }秒`
|
||||
},
|
||||
formatNetSpeed(netSpeedMB) {
|
||||
netSpeedMB = Number(netSpeedMB) || 0
|
||||
|
||||
@@ -83,7 +83,7 @@ const useStore = defineStore('global', {
|
||||
await this.getPlusInfo()
|
||||
this.getAIConfig()
|
||||
this.getChatHistory()
|
||||
this.wsClientsStatus()
|
||||
// this.wsClientsStatus()
|
||||
},
|
||||
async getHostList() {
|
||||
let { data: newHostList } = await $api.getHostList()
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
<template>
|
||||
<div class="info_container" :style="{ width: visible ? `250px` : 0 }">
|
||||
<!-- <el-divider class="first-divider" content-position="center">地理位置</el-divider> -->
|
||||
<el-descriptions
|
||||
class="margin-top"
|
||||
:column="1"
|
||||
size="small"
|
||||
border
|
||||
>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
IP
|
||||
</div>
|
||||
</template>
|
||||
<span class="host-info-ip" :title="host">{{ host }}</span>
|
||||
<template v-if="pingMs">
|
||||
<el-tooltip effect="dark" content="该值为EasyNode服务端主机到目标主机的ping值" placement="bottom">
|
||||
<span class="host-ping" :style="{backgroundColor: handlePingColor(pingMs)}">{{ pingMs }}ms</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<el-tag size="small" style="cursor: pointer;margin-left: 10px;" @click="handleCopy">复制</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
在线
|
||||
</div>
|
||||
</template>
|
||||
<div size="small">
|
||||
{{ $tools.formatTime(osInfo.uptime, 'day') }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- <el-divider content-position="center">实时监控</el-divider> -->
|
||||
<br>
|
||||
|
||||
<el-descriptions
|
||||
class="margin-top"
|
||||
:column="1"
|
||||
size="small"
|
||||
border
|
||||
>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
CPU
|
||||
</div>
|
||||
</template>
|
||||
<el-progress
|
||||
:text-inside="true"
|
||||
:stroke-width="18"
|
||||
:percentage="cpuUsage"
|
||||
:color="handleUsedColor(cpuUsage)"
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
负载
|
||||
</div>
|
||||
</template>
|
||||
<el-tooltip effect="dark" content="过去1分钟、5分钟、15分钟CPU负载" placement="bottom">
|
||||
<div class="load-avg-display">
|
||||
<span
|
||||
v-for="(load, index) in loadAvgFormatted"
|
||||
:key="index"
|
||||
:class="['load-value', { 'high-load': load.isHigh }]"
|
||||
>
|
||||
{{ load.value }}{{ index < loadAvgFormatted.length - 1 ? ',' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
内存
|
||||
</div>
|
||||
</template>
|
||||
<el-progress
|
||||
:text-inside="true"
|
||||
:stroke-width="18"
|
||||
:percentage="usedMemPercentage"
|
||||
:color="handleUsedColor(usedMemPercentage)"
|
||||
/>
|
||||
<div class="position-right">
|
||||
{{ $tools.toFixed(memInfo.usedMemMb / 1024) }}/{{ $tools.toFixed(memInfo.totalMemMb / 1024) }}G
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
交换
|
||||
</div>
|
||||
</template>
|
||||
<el-progress
|
||||
:text-inside="true"
|
||||
:stroke-width="18"
|
||||
:percentage="swapPercentage"
|
||||
:color="handleUsedColor(swapPercentage)"
|
||||
/>
|
||||
<div class="position-right">
|
||||
{{ $tools.toFixed(swapInfo.swapUsed / 1024) }}/{{ $tools.toFixed(swapInfo.swapTotal / 1024) }}G
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
硬盘
|
||||
</div>
|
||||
</template>
|
||||
<el-progress
|
||||
:text-inside="true"
|
||||
:stroke-width="18"
|
||||
:percentage="usedPercentage"
|
||||
:color="handleUsedColor(usedPercentage)"
|
||||
/>
|
||||
<div class="position-right">
|
||||
{{ driveInfo.usedGb || '--' }}/{{ driveInfo.totalGb || '--' }}G
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
网络
|
||||
</div>
|
||||
</template>
|
||||
<div class="netstat-info">
|
||||
<div class="count_wrap">
|
||||
<div class="wrap">
|
||||
<img src="@/assets/upload.png" alt="">
|
||||
<span class="upload">{{ output || 0 }}</span>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<img src="@/assets/download.png" alt="">
|
||||
<span class="download">{{ input || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas ref="networkChart" width="200" height="80" />
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- <el-divider content-position="center">系统信息</el-divider> -->
|
||||
<br>
|
||||
|
||||
<el-descriptions
|
||||
class="margin-top"
|
||||
:column="1"
|
||||
size="small"
|
||||
border
|
||||
>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
名称
|
||||
</div>
|
||||
</template>
|
||||
<div size="small">
|
||||
{{ osInfo.hostname }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
核心
|
||||
</div>
|
||||
</template>
|
||||
<div size="small">
|
||||
{{ cpuInfo.cpuCount }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
型号
|
||||
</div>
|
||||
</template>
|
||||
<div size="small">
|
||||
{{ cpuInfo.cpuModel }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="item-title">
|
||||
类型
|
||||
</div>
|
||||
</template>
|
||||
<div size="small">
|
||||
{{ osInfo.type }} {{ osInfo.release }} {{ osInfo.arch }}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, getCurrentInstance, watch, onBeforeUnmount, nextTick, onMounted, toRaw, shallowRef } from 'vue'
|
||||
import { io } from 'socket.io-client'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
// 注册Chart.js所有组件
|
||||
Chart.register(...registerables)
|
||||
|
||||
const { proxy: { $message, $tools, $store } } = getCurrentInstance()
|
||||
|
||||
const props = defineProps({
|
||||
hostId: {
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
visible: {
|
||||
required: true,
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
|
||||
// 状态数据
|
||||
const serverData = ref({
|
||||
connect: false,
|
||||
cpuInfo: {},
|
||||
memInfo: {},
|
||||
swapInfo: {},
|
||||
driveInfo: {},
|
||||
netstatInfo: {},
|
||||
osInfo: {}
|
||||
})
|
||||
|
||||
const pingData = ref({})
|
||||
const socket = ref(null)
|
||||
const reconnectTimer = ref(null)
|
||||
const reconnectAttempts = ref(0)
|
||||
const maxReconnectAttempts = 10
|
||||
const reconnectInterval = 3000 // 3秒重连间隔
|
||||
const isConnecting = ref(false)
|
||||
|
||||
// 网速图表相关
|
||||
const networkChart = ref(null) // canvas元素引用
|
||||
const chartInstance = shallowRef(null) // Chart.js实例,使用shallowRef避免深度响应式
|
||||
const networkHistory = shallowRef({
|
||||
upload: [], // 上传速度历史数据
|
||||
download: [], // 下载速度历史数据
|
||||
timestamps: [] // 时间戳
|
||||
})
|
||||
const maxHistoryLength = 120 // 保存120次数据(约2分钟)
|
||||
|
||||
// 计算属性
|
||||
const host = computed(() => {
|
||||
const hostInfo = $store.hostList.find(h => h.id === props.hostId)
|
||||
return hostInfo?.host || ''
|
||||
})
|
||||
|
||||
const cpuInfo = computed(() => serverData.value.cpuInfo || {})
|
||||
const memInfo = computed(() => serverData.value.memInfo || {})
|
||||
const swapInfo = computed(() => serverData.value.swapInfo || {})
|
||||
const osInfo = computed(() => serverData.value.osInfo || {})
|
||||
const driveInfo = computed(() => serverData.value.driveInfo || {})
|
||||
const netstatInfo = computed(() => {
|
||||
let { total: netTotal, ...netCards } = serverData.value.netstatInfo || {}
|
||||
return { netTotal, netCards: netCards || {} }
|
||||
})
|
||||
|
||||
const cpuUsage = computed(() => Number(cpuInfo.value?.cpuUsage) || 0)
|
||||
const cpuCount = computed(() => Number(cpuInfo.value?.cpuCount) || 1)
|
||||
const loadAvg = computed(() => cpuInfo.value?.loadAvg || [0, 0, 0,])
|
||||
|
||||
// 格式化负载数据,包含颜色判断
|
||||
const loadAvgFormatted = computed(() => {
|
||||
return loadAvg.value.map(load => {
|
||||
const loadValue = Number(load) || 0
|
||||
return {
|
||||
value: loadValue.toFixed(2),
|
||||
isHigh: loadValue >= cpuCount.value // 负载超过CPU核心数时认为是高负载
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const usedMemPercentage = computed(() => Number(memInfo.value?.usedMemPercentage) || 0)
|
||||
const swapPercentage = computed(() => {
|
||||
let swapPercentage = swapInfo.value?.swapPercentage
|
||||
let isNaN = swapPercentage === 'NaN' || Number.isNaN(swapInfo.value?.swapPercentage)
|
||||
return isNaN ? 0 : Number(swapPercentage || 0)
|
||||
})
|
||||
const usedPercentage = computed(() => Number(driveInfo.value?.usedPercentage) || 0)
|
||||
|
||||
const output = computed(() => {
|
||||
let outputMb = Number(netstatInfo.value.netTotal?.outputMb) || 0
|
||||
if (outputMb >= 1) return `${ outputMb.toFixed(2) } MB/s`
|
||||
return `${ (outputMb * 1024).toFixed(1) } KB/s`
|
||||
})
|
||||
|
||||
const input = computed(() => {
|
||||
let inputMb = Number(netstatInfo.value.netTotal?.inputMb) || 0
|
||||
if (inputMb >= 1) return `${ inputMb.toFixed(2) } MB/s`
|
||||
return `${ (inputMb * 1024).toFixed(1) } KB/s`
|
||||
})
|
||||
|
||||
const pingMs = computed(() => {
|
||||
let curPingData = pingData.value[host.value] || {}
|
||||
if (!curPingData?.success) return false
|
||||
return Number(curPingData?.time).toFixed(0)
|
||||
})
|
||||
|
||||
// 清理重连定时器
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimer.value) {
|
||||
clearTimeout(reconnectTimer.value)
|
||||
reconnectTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 启动重连
|
||||
const startReconnect = () => {
|
||||
if (reconnectAttempts.value >= maxReconnectAttempts) {
|
||||
console.warn('server-status websocket 已达到最大重连次数,停止重连')
|
||||
return
|
||||
}
|
||||
|
||||
clearReconnectTimer()
|
||||
|
||||
reconnectTimer.value = setTimeout(() => {
|
||||
if (props.visible && props.hostId && !isConnecting.value) {
|
||||
reconnectAttempts.value++
|
||||
console.log(`server-status websocket 尝试重连 (${ reconnectAttempts.value }/${ maxReconnectAttempts })`)
|
||||
connectWebSocket()
|
||||
}
|
||||
}, reconnectInterval)
|
||||
}
|
||||
|
||||
// WebSocket连接函数
|
||||
const connectWebSocket = () => {
|
||||
if (isConnecting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isConnecting.value = true
|
||||
|
||||
// 如果已有连接,先断开
|
||||
if (socket.value) {
|
||||
socket.value.disconnect()
|
||||
socket.value = null
|
||||
}
|
||||
|
||||
try {
|
||||
socket.value = io($store.serviceURI, {
|
||||
path: '/server-status',
|
||||
forceNew: true,
|
||||
timeout: 10000, // 10秒连接超时
|
||||
reconnection: false // 禁用socket.io自带重连,使用自定义重连
|
||||
})
|
||||
|
||||
socket.value.on('connect', () => {
|
||||
console.log('server-status websocket 已连接:', socket.value.id)
|
||||
isConnecting.value = false
|
||||
reconnectAttempts.value = 0 // 重置重连计数器
|
||||
clearReconnectTimer()
|
||||
|
||||
const token = $store.token
|
||||
socket.value.emit('ws_server_status', { hostId: props.hostId, token })
|
||||
})
|
||||
|
||||
socket.value.on('server_status_data', (data) => {
|
||||
serverData.value = data
|
||||
|
||||
// 更新网速图表数据
|
||||
if (data.connect && data.netstatInfo && data.netstatInfo.total) {
|
||||
const uploadMb = data.netstatInfo.total.outputMb || 0
|
||||
const downloadMb = data.netstatInfo.total.inputMb || 0
|
||||
updateNetworkHistory(uploadMb, downloadMb)
|
||||
}
|
||||
|
||||
// 如果服务器返回错误状态,停止重连并断开连接
|
||||
if (data.error) {
|
||||
console.warn('服务器状态监控出现关键错误:', data.errorReason)
|
||||
// 停止重连机制
|
||||
clearReconnectTimer()
|
||||
reconnectAttempts.value = maxReconnectAttempts // 设置为最大值,防止重连
|
||||
|
||||
console.warn(`服务器状态监控已停止: ${ data.errorReason }`)
|
||||
}
|
||||
})
|
||||
|
||||
socket.value.on('token_verify_fail', (message) => {
|
||||
console.log('token 验证失败:', message)
|
||||
isConnecting.value = false
|
||||
// token失败不重连,需要重新登录
|
||||
})
|
||||
|
||||
socket.value.on('disconnect', (reason) => {
|
||||
console.log('server-status websocket 连接断开:', reason)
|
||||
isConnecting.value = false
|
||||
|
||||
// 只有在组件仍然可见且需要连接时才重连
|
||||
if (props.visible && props.hostId && reason !== 'io client disconnect') {
|
||||
startReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
socket.value.on('connect_error', (error) => {
|
||||
console.error('server-status websocket 连接出错:', error.message)
|
||||
isConnecting.value = false
|
||||
|
||||
// 连接错误时启动重连
|
||||
if (props.visible && props.hostId) {
|
||||
startReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
socket.value.on('error', (error) => {
|
||||
console.error('server-status websocket 发生错误:', error)
|
||||
isConnecting.value = false
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建 server-status websocket 连接失败:', error)
|
||||
isConnecting.value = false
|
||||
if (props.visible && props.hostId) {
|
||||
startReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化WebSocket连接
|
||||
const initWebSocket = () => {
|
||||
// 重置重连状态
|
||||
reconnectAttempts.value = 0
|
||||
clearReconnectTimer()
|
||||
connectWebSocket()
|
||||
}
|
||||
|
||||
// 断开WebSocket连接
|
||||
const disconnectWebSocket = () => {
|
||||
clearReconnectTimer()
|
||||
isConnecting.value = false
|
||||
|
||||
if (socket.value) {
|
||||
socket.value.disconnect()
|
||||
socket.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 监听hostId变化重新连接
|
||||
watch(
|
||||
() => props.hostId,
|
||||
(newHostId) => {
|
||||
if (newHostId && props.visible) {
|
||||
initWebSocket()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && props.hostId) {
|
||||
initWebSocket()
|
||||
} else if (!newVisible) {
|
||||
disconnectWebSocket()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 工具函数
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(host.value)
|
||||
$message.success({ message: '复制成功', center: true })
|
||||
}
|
||||
|
||||
const handleUsedColor = (num) => {
|
||||
if (num < 60) return '#13ce66'
|
||||
if (num < 80) return '#e6a23c'
|
||||
if (num <= 100) return '#ff4949'
|
||||
}
|
||||
|
||||
const handlePingColor = (num) => {
|
||||
if (num < 100) return 'rgba(19, 206, 102, 0.5)' // #13ce66
|
||||
if (num < 250) return 'rgba(230, 162, 60, 0.5)' // #e6a23c
|
||||
return 'rgba(255, 73, 73, 0.5)' // #ff4949
|
||||
}
|
||||
|
||||
// 初始化网速图表
|
||||
const initNetworkChart = () => {
|
||||
if (!networkChart.value) return
|
||||
|
||||
const ctx = networkChart.value.getContext('2d')
|
||||
|
||||
chartInstance.value = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [], // 时间标签
|
||||
datasets: [
|
||||
{
|
||||
label: '上传',
|
||||
data: [],
|
||||
borderColor: '#CF8A20',
|
||||
backgroundColor: 'rgba(207, 138, 32, 0.1)',
|
||||
borderWidth: 1.5,
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3
|
||||
},
|
||||
{
|
||||
label: '下载',
|
||||
data: [],
|
||||
borderColor: '#67c23a',
|
||||
backgroundColor: 'rgba(103, 194, 58, 0.1)',
|
||||
borderWidth: 1.5,
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
scales: {
|
||||
x: {
|
||||
display: false, // 隐藏x轴标签
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
position: 'left',
|
||||
ticks: {
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1) {
|
||||
return value.toFixed(1) + 'M'
|
||||
}
|
||||
return (value * 1024).toFixed(0) + 'K'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false // 隐藏图例
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function() {
|
||||
return '' // 隐藏tooltip标题(时间)
|
||||
},
|
||||
label: function(context) {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label
|
||||
if (value >= 1) {
|
||||
return `${ label }: ${ value.toFixed(2) } MB/s`
|
||||
}
|
||||
return `${ label }: ${ (value * 1024).toFixed(1) } KB/s`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新网速历史数据
|
||||
const updateNetworkHistory = (uploadMb, downloadMb) => {
|
||||
const now = new Date()
|
||||
const timeLabel = now.toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
|
||||
// 获取原始数据并直接操作,避免响应式追踪
|
||||
const history = toRaw(networkHistory.value)
|
||||
|
||||
// 添加新数据
|
||||
history.upload.push(parseFloat(uploadMb) || 0)
|
||||
history.download.push(parseFloat(downloadMb) || 0)
|
||||
history.timestamps.push(timeLabel)
|
||||
|
||||
// 保持数据长度不超过maxHistoryLength
|
||||
if (history.upload.length > maxHistoryLength) {
|
||||
history.upload.shift()
|
||||
history.download.shift()
|
||||
history.timestamps.shift()
|
||||
}
|
||||
|
||||
// 手动触发响应式更新
|
||||
networkHistory.value = { ...history }
|
||||
|
||||
// 更新图表 - 直接使用原始数据
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.data.labels = [...history.timestamps,]
|
||||
chartInstance.value.data.datasets[0].data = [...history.upload,]
|
||||
chartInstance.value.data.datasets[1].data = [...history.download,]
|
||||
chartInstance.value.update('none') // 不使用动画以提高性能
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 等待DOM渲染完成后初始化图表
|
||||
nextTick(() => {
|
||||
initNetworkChart()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 清理图表实例
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.destroy()
|
||||
chartInstance.value = null
|
||||
}
|
||||
|
||||
// 清理历史数据
|
||||
networkHistory.value = {
|
||||
upload: [],
|
||||
download: [],
|
||||
timestamps: []
|
||||
}
|
||||
|
||||
// 清理WebSocket连接和重连定时器
|
||||
disconnectWebSocket()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.info_container {
|
||||
transition: all 0.15s;
|
||||
height: 100%;
|
||||
|
||||
// 连接状态指示器
|
||||
.connection-status {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.reconnect-info {
|
||||
color: var(--el-color-warning);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&.connected {
|
||||
.status-dot {
|
||||
background-color: var(--el-color-success);
|
||||
box-shadow: 0 0 4px rgba(103, 194, 58, 0.6);
|
||||
}
|
||||
.status-text {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
&.connecting {
|
||||
.status-dot {
|
||||
background-color: var(--el-color-warning);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
.status-text {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
.status-dot {
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
.status-text {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
.status-dot {
|
||||
background-color: var(--el-color-danger);
|
||||
animation: none; // 停止动画
|
||||
}
|
||||
.status-text {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-info {
|
||||
display: block;
|
||||
color: var(--el-color-danger);
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
font-weight: normal;
|
||||
opacity: 0.8;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.item-title {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
min-width: 30px;
|
||||
max-width: 30px;
|
||||
}
|
||||
|
||||
.host-info-ip {
|
||||
word-break: break-all;
|
||||
max-width: 92px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.host-ping {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.load-avg-display {
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
|
||||
.load-value {
|
||||
color: #13ce66;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&.high-load {
|
||||
color: #ff4949;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分割线title
|
||||
:deep(.el-divider__text) {
|
||||
color: #a0cfff;
|
||||
padding: 0 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// 分割线间距
|
||||
:deep(.el-divider--horizontal) {
|
||||
margin: 28px 0 10px;
|
||||
}
|
||||
|
||||
.first-divider {
|
||||
margin: 15px 0 10px;
|
||||
}
|
||||
|
||||
// 表格
|
||||
:deep(.el-descriptions__table) {
|
||||
tr {
|
||||
display: flex;
|
||||
|
||||
.el-descriptions__label {
|
||||
min-width: 35px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.el-descriptions__content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.el-progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 进度条右边参数定位
|
||||
.position-right {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条
|
||||
:deep(.el-progress-bar__inner) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.el-progress-bar__innerText {
|
||||
display: flex;
|
||||
|
||||
span {
|
||||
color: #242323;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 网络
|
||||
.netstat-info {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.count_wrap {
|
||||
display: flex;
|
||||
// flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// justify-content: center;
|
||||
padding: 0 5px;
|
||||
|
||||
img {
|
||||
width: 15px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.upload {
|
||||
color: #CF8A20;
|
||||
}
|
||||
|
||||
.download {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
margin-top: 5px;
|
||||
position: relative;
|
||||
|
||||
canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.el-descriptions__label {
|
||||
vertical-align: middle;
|
||||
max-width: 35px;
|
||||
}
|
||||
</style>
|
||||
@@ -282,20 +282,18 @@
|
||||
direction="ltr"
|
||||
class="mobile_menu_drawer"
|
||||
>
|
||||
<InfoSide
|
||||
<ServerStatus
|
||||
ref="infoSideRef"
|
||||
:host-info="curHost"
|
||||
:visible="visible"
|
||||
:ping-data="pingData"
|
||||
:host-id="item.id"
|
||||
:visible="showInfoSide"
|
||||
/>
|
||||
</el-drawer>
|
||||
<!-- PC端 -->
|
||||
<div v-else :class="['tab_content_main_info_side', { 'show_info_side': showInfoSide }]">
|
||||
<InfoSide
|
||||
<ServerStatus
|
||||
ref="infoSideRef"
|
||||
:host-info="curHost"
|
||||
:visible="visible"
|
||||
:ping-data="pingData"
|
||||
:host-id="item.id"
|
||||
:visible="showInfoSide"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -393,7 +391,8 @@ import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import useMobileWidth from '@/composables/useMobileWidth'
|
||||
import { terminalStatusList } from '@/utils/enum'
|
||||
import Terminal from './terminal.vue'
|
||||
import InfoSide from './info-side.vue'
|
||||
// import InfoSide from './info-side.vue'
|
||||
import ServerStatus from './server-status.vue'
|
||||
import HostForm from '../../server/components/host-form.vue'
|
||||
import TerminalSetting from './terminal-setting.vue'
|
||||
import FooterBar from './footer-bar.vue'
|
||||
|
||||
@@ -865,6 +865,11 @@
|
||||
dependencies:
|
||||
vary "^1.1.2"
|
||||
|
||||
"@kurkle/color@^0.3.0":
|
||||
version "0.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
|
||||
integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
|
||||
|
||||
"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0", "@lezer/common@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.1.tgz#198b278b7869668e1bebbe687586e12a42731049"
|
||||
@@ -2028,6 +2033,13 @@ chalk@^4.0.0, chalk@^4.1.1, chalk@^4.1.2:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chart.js@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.0.tgz#11a1ef6c4befc514b1b0b613ebac226c4ad2740b"
|
||||
integrity sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==
|
||||
dependencies:
|
||||
"@kurkle/color" "^0.3.0"
|
||||
|
||||
chokidar@^3.5.2, chokidar@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
|
||||
|
||||
Reference in New Issue
Block a user