feat: 新增终端顶部实时服务器监控面板
This commit is contained in:
@@ -1,20 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import axios from 'axios'
|
||||
import { Activity, Cpu, HardDrive, MemoryStick, Clock, ChevronUp, ChevronDown } from 'lucide-vue-next'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: number
|
||||
active?: boolean
|
||||
}>()
|
||||
const props = defineProps<{
|
||||
connectionId: number
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
|
||||
const errorMessage = ref('')
|
||||
const showMonitor = ref(true)
|
||||
const monitorLoading = ref(false)
|
||||
const monitorData = ref<{
|
||||
cpuUsage?: number
|
||||
memTotal?: number
|
||||
memUsed?: number
|
||||
memUsage?: number
|
||||
diskUsage?: number
|
||||
load1?: number
|
||||
cpuCores?: number
|
||||
uptime?: string
|
||||
}>({})
|
||||
let monitorPollTimer: number | null = null
|
||||
|
||||
let term: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
@@ -34,11 +49,51 @@ function focusTerminal() {
|
||||
term?.focus()
|
||||
}
|
||||
|
||||
function refreshTerminalLayout() {
|
||||
nextTick(() => {
|
||||
fitTerminal()
|
||||
focusTerminal()
|
||||
})
|
||||
function refreshTerminalLayout() {
|
||||
nextTick(() => {
|
||||
fitTerminal()
|
||||
focusTerminal()
|
||||
})
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
function getUsageColor(usage: number | undefined, threshold: number = 70, critical: number = 90): string {
|
||||
if (usage === undefined) return 'text-slate-400'
|
||||
if (usage >= critical) return 'text-red-400'
|
||||
if (usage >= threshold) return 'text-amber-400'
|
||||
return 'text-green-400'
|
||||
}
|
||||
|
||||
async function fetchMonitorData() {
|
||||
if (!props.active || status.value !== 'connected') return
|
||||
monitorLoading.value = true
|
||||
try {
|
||||
const res = await axios.get(`/api/monitor/${props.connectionId}`)
|
||||
monitorData.value = res.data
|
||||
} catch {
|
||||
// 静默失败,不影响终端使用
|
||||
} finally {
|
||||
monitorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startMonitorPoll() {
|
||||
stopMonitorPoll()
|
||||
fetchMonitorData()
|
||||
monitorPollTimer = window.setInterval(fetchMonitorData, 5000)
|
||||
}
|
||||
|
||||
function stopMonitorPoll() {
|
||||
if (monitorPollTimer) {
|
||||
clearInterval(monitorPollTimer)
|
||||
monitorPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function getWsUrl(): string {
|
||||
@@ -52,32 +107,34 @@ function getWsUrl(): string {
|
||||
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (resizeObserver && containerRef.value) {
|
||||
resizeObserver.unobserve(containerRef.value)
|
||||
}
|
||||
resizeObserver = null
|
||||
|
||||
if (onDataDisposable) {
|
||||
onDataDisposable.dispose()
|
||||
onDataDisposable = null
|
||||
}
|
||||
if (onResizeDisposable) {
|
||||
onResizeDisposable.dispose()
|
||||
onResizeDisposable = null
|
||||
}
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeDebounceTimer = 0
|
||||
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (term) {
|
||||
term.dispose()
|
||||
term = null
|
||||
}
|
||||
fitAddon = null
|
||||
function cleanup() {
|
||||
stopMonitorPoll()
|
||||
|
||||
if (resizeObserver && containerRef.value) {
|
||||
resizeObserver.unobserve(containerRef.value)
|
||||
}
|
||||
resizeObserver = null
|
||||
|
||||
if (onDataDisposable) {
|
||||
onDataDisposable.dispose()
|
||||
onDataDisposable = null
|
||||
}
|
||||
if (onResizeDisposable) {
|
||||
onResizeDisposable.dispose()
|
||||
onResizeDisposable = null
|
||||
}
|
||||
clearTimeout(resizeDebounceTimer)
|
||||
resizeDebounceTimer = 0
|
||||
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (term) {
|
||||
term.dispose()
|
||||
term = null
|
||||
}
|
||||
fitAddon = null
|
||||
}
|
||||
|
||||
function sendResize(cols: number, rows: number) {
|
||||
@@ -154,10 +211,13 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
status.value = 'connected'
|
||||
refreshTerminalLayout()
|
||||
scheduleResize(term!.cols, term!.rows)
|
||||
ws.onopen = () => {
|
||||
status.value = 'connected'
|
||||
refreshTerminalLayout()
|
||||
scheduleResize(term!.cols, term!.rows)
|
||||
if (props.active !== false) {
|
||||
startMonitorPoll()
|
||||
}
|
||||
}
|
||||
|
||||
onDataDisposable = term.onData((data) => {
|
||||
@@ -195,10 +255,15 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.active, (active) => {
|
||||
if (active) {
|
||||
refreshTerminalLayout()
|
||||
}
|
||||
watch(() => props.active, (active) => {
|
||||
if (active) {
|
||||
refreshTerminalLayout()
|
||||
if (status.value === 'connected') {
|
||||
startMonitorPoll()
|
||||
}
|
||||
} else {
|
||||
stopMonitorPoll()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -208,6 +273,70 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<!-- 监控状态栏 -->
|
||||
<div v-if="status === 'connected'" class="border-b border-slate-700 bg-slate-900/80">
|
||||
<div class="flex items-center justify-between px-3 py-1.5 text-xs">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- CPU -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Cpu class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">CPU:</span>
|
||||
<span :class="getUsageColor(monitorData.cpuUsage)">
|
||||
{{ monitorData.cpuUsage !== undefined ? monitorData.cpuUsage + '%' : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 内存 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<MemoryStick class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">MEM:</span>
|
||||
<span :class="getUsageColor(monitorData.memUsage)">
|
||||
{{ monitorData.memUsage !== undefined ? monitorData.memUsage + '%' : '--' }}
|
||||
</span>
|
||||
<span v-if="monitorData.memUsed && monitorData.memTotal" class="text-slate-500 ml-1">
|
||||
({{ formatBytes(monitorData.memUsed) }}/{{ formatBytes(monitorData.memTotal) }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 磁盘 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<HardDrive class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">DISK:</span>
|
||||
<span :class="getUsageColor(monitorData.diskUsage, 80, 95)">
|
||||
{{ monitorData.diskUsage !== undefined ? monitorData.diskUsage + '%' : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 负载 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Activity class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">LOAD:</span>
|
||||
<span :class="getUsageColor(monitorData.load1, monitorData.cpuCores || 4, (monitorData.cpuCores || 4) * 1.5)">
|
||||
{{ monitorData.load1 !== undefined ? monitorData.load1.toFixed(2) : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 运行时间 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="w-3.5 h-3.5 text-slate-400" />
|
||||
<span class="text-slate-400">UP:</span>
|
||||
<span class="text-slate-300">
|
||||
{{ monitorData.uptime || '--' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 折叠按钮 -->
|
||||
<button
|
||||
@click="showMonitor = !showMonitor"
|
||||
class="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
|
||||
:title="showMonitor ? '隐藏监控栏' : '显示监控栏'"
|
||||
>
|
||||
<component :is="showMonitor ? ChevronUp : ChevronDown" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="flex-1 min-h-0 p-4 xterm-container"
|
||||
|
||||
Reference in New Issue
Block a user