feat: 新增终端顶部实时服务器监控面板
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.entity.User;
|
||||||
|
import com.sshmanager.repository.UserRepository;
|
||||||
|
import com.sshmanager.service.ConnectionService;
|
||||||
|
import com.sshmanager.service.SshService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/monitor")
|
||||||
|
public class MonitorController {
|
||||||
|
|
||||||
|
private final ConnectionService connectionService;
|
||||||
|
private final SshService sshService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public MonitorController(ConnectionService connectionService, SshService sshService, UserRepository userRepository) {
|
||||||
|
this.connectionService = connectionService;
|
||||||
|
this.sshService = sshService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getCurrentUserId(Authentication auth) {
|
||||||
|
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{connectionId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getServerMetrics(@PathVariable Long connectionId, Authentication authentication) {
|
||||||
|
try {
|
||||||
|
Long userId = getCurrentUserId(authentication);
|
||||||
|
Connection conn = connectionService.getConnectionForSsh(connectionId, userId);
|
||||||
|
if (conn == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String password = connectionService.getDecryptedPassword(conn);
|
||||||
|
String privateKey = connectionService.getDecryptedPrivateKey(conn);
|
||||||
|
String passphrase = connectionService.getDecryptedPassphrase(conn);
|
||||||
|
|
||||||
|
Map<String, Object> metrics = new HashMap<>();
|
||||||
|
|
||||||
|
// 获取CPU使用率
|
||||||
|
try {
|
||||||
|
String cpuOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - $1}'");
|
||||||
|
double cpuUsage = Double.parseDouble(cpuOutput.trim());
|
||||||
|
metrics.put("cpuUsage", Math.round(cpuUsage * 10.0) / 10.0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
metrics.put("cpuUsage", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取内存信息
|
||||||
|
try {
|
||||||
|
String memOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"free -b | grep Mem");
|
||||||
|
String[] memParts = memOutput.trim().split("\\s+");
|
||||||
|
long totalMem = Long.parseLong(memParts[1]);
|
||||||
|
long usedMem = Long.parseLong(memParts[2]);
|
||||||
|
double memUsage = (double) usedMem / totalMem * 100;
|
||||||
|
metrics.put("memTotal", totalMem);
|
||||||
|
metrics.put("memUsed", usedMem);
|
||||||
|
metrics.put("memUsage", Math.round(memUsage * 10.0) / 10.0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
metrics.put("memTotal", null);
|
||||||
|
metrics.put("memUsed", null);
|
||||||
|
metrics.put("memUsage", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取磁盘使用率
|
||||||
|
try {
|
||||||
|
String diskOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"df -P / | tail -1");
|
||||||
|
String[] diskParts = diskOutput.trim().split("\\s+");
|
||||||
|
int diskUsage = Integer.parseInt(diskParts[4].replace("%", ""));
|
||||||
|
metrics.put("diskUsage", diskUsage);
|
||||||
|
} catch (Exception e) {
|
||||||
|
metrics.put("diskUsage", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取系统负载
|
||||||
|
try {
|
||||||
|
String loadOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"cat /proc/loadavg");
|
||||||
|
String[] loadParts = loadOutput.trim().split("\\s+");
|
||||||
|
double load1 = Double.parseDouble(loadParts[0]);
|
||||||
|
metrics.put("load1", load1);
|
||||||
|
} catch (Exception e) {
|
||||||
|
metrics.put("load1", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取CPU核数
|
||||||
|
try {
|
||||||
|
String cpuCountOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"grep -c ^processor /proc/cpuinfo");
|
||||||
|
int cpuCores = Integer.parseInt(cpuCountOutput.trim());
|
||||||
|
metrics.put("cpuCores", cpuCores);
|
||||||
|
} catch (Exception e) {
|
||||||
|
metrics.put("cpuCores", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取系统运行时间
|
||||||
|
try {
|
||||||
|
String uptimeOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"uptime -p");
|
||||||
|
metrics.put("uptime", uptimeOutput.trim().replace("up ", ""));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 尝试另一种uptime格式
|
||||||
|
try {
|
||||||
|
String uptimeOutput = sshService.executeCommand(conn, password, privateKey, passphrase,
|
||||||
|
"cat /proc/uptime | awk '{print $1}'");
|
||||||
|
double uptimeSeconds = Double.parseDouble(uptimeOutput.trim());
|
||||||
|
long days = (long) (uptimeSeconds / 86400);
|
||||||
|
long hours = (long) ((uptimeSeconds % 86400) / 3600);
|
||||||
|
long minutes = (long) ((uptimeSeconds % 3600) / 60);
|
||||||
|
StringBuilder uptimeStr = new StringBuilder();
|
||||||
|
if (days > 0) uptimeStr.append(days).append("d ");
|
||||||
|
if (hours > 0) uptimeStr.append(hours).append("h ");
|
||||||
|
uptimeStr.append(minutes).append("m");
|
||||||
|
metrics.put("uptime", uptimeStr.toString().trim());
|
||||||
|
} catch (Exception e2) {
|
||||||
|
metrics.put("uptime", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(metrics);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("error", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
package com.sshmanager.service;
|
package com.sshmanager.service;
|
||||||
|
|
||||||
import com.jcraft.jsch.ChannelShell;
|
import com.jcraft.jsch.ChannelExec;
|
||||||
import com.jcraft.jsch.JSch;
|
import com.jcraft.jsch.ChannelShell;
|
||||||
import com.jcraft.jsch.Session;
|
import com.jcraft.jsch.JSch;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.jcraft.jsch.Session;
|
||||||
import org.springframework.stereotype.Service;
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.io.PipedInputStream;
|
import java.io.PipedInputStream;
|
||||||
import java.io.PipedOutputStream;
|
import java.io.PipedOutputStream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SshService {
|
public class SshService {
|
||||||
|
|
||||||
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
JSch jsch = new JSch();
|
JSch jsch = new JSch();
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||||
@@ -65,7 +68,49 @@ public class SshService {
|
|||||||
}
|
}
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
return new SshSession(session, channel, channelOut, pipeToChannel);
|
return new SshSession(session, channel, channelOut, pipeToChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行单次命令并返回输出
|
||||||
|
public String executeCommand(Connection conn, String password, String privateKey, String passphrase, String command) throws Exception {
|
||||||
|
JSch jsch = new JSch();
|
||||||
|
|
||||||
|
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||||
|
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||||
|
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||||
|
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||||
|
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||||
|
session.setConfig("StrictHostKeyChecking", "no");
|
||||||
|
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||||
|
session.setConfig("PreferredAuthentications", conn.getAuthType() == Connection.AuthType.PASSWORD ? "password" : "publickey");
|
||||||
|
|
||||||
|
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||||
|
session.setPassword(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.connect(8000);
|
||||||
|
|
||||||
|
ChannelExec channel = (ChannelExec) session.openChannel("exec");
|
||||||
|
channel.setCommand(command);
|
||||||
|
channel.setErrStream(System.err);
|
||||||
|
|
||||||
|
InputStream in = channel.getInputStream();
|
||||||
|
channel.connect(3000);
|
||||||
|
|
||||||
|
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
result.append(line).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.disconnect();
|
||||||
|
session.disconnect();
|
||||||
|
|
||||||
|
return result.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SshSession {
|
public static class SshSession {
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { Terminal } from 'xterm'
|
import { Terminal } from 'xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { useAuthStore } from '../stores/auth'
|
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'
|
import 'xterm/css/xterm.css'
|
||||||
|
|
||||||
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
connectionId: number
|
connectionId: number
|
||||||
active?: boolean
|
active?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
|
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
|
||||||
const errorMessage = ref('')
|
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 term: Terminal | null = null
|
||||||
let fitAddon: FitAddon | null = null
|
let fitAddon: FitAddon | null = null
|
||||||
@@ -34,11 +49,51 @@ function focusTerminal() {
|
|||||||
term?.focus()
|
term?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshTerminalLayout() {
|
function refreshTerminalLayout() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
fitTerminal()
|
fitTerminal()
|
||||||
focusTerminal()
|
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 {
|
function getWsUrl(): string {
|
||||||
@@ -52,32 +107,34 @@ function getWsUrl(): string {
|
|||||||
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
|
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
if (resizeObserver && containerRef.value) {
|
stopMonitorPoll()
|
||||||
resizeObserver.unobserve(containerRef.value)
|
|
||||||
}
|
if (resizeObserver && containerRef.value) {
|
||||||
resizeObserver = null
|
resizeObserver.unobserve(containerRef.value)
|
||||||
|
}
|
||||||
if (onDataDisposable) {
|
resizeObserver = null
|
||||||
onDataDisposable.dispose()
|
|
||||||
onDataDisposable = null
|
if (onDataDisposable) {
|
||||||
}
|
onDataDisposable.dispose()
|
||||||
if (onResizeDisposable) {
|
onDataDisposable = null
|
||||||
onResizeDisposable.dispose()
|
}
|
||||||
onResizeDisposable = null
|
if (onResizeDisposable) {
|
||||||
}
|
onResizeDisposable.dispose()
|
||||||
clearTimeout(resizeDebounceTimer)
|
onResizeDisposable = null
|
||||||
resizeDebounceTimer = 0
|
}
|
||||||
|
clearTimeout(resizeDebounceTimer)
|
||||||
if (ws) {
|
resizeDebounceTimer = 0
|
||||||
ws.close()
|
|
||||||
ws = null
|
if (ws) {
|
||||||
}
|
ws.close()
|
||||||
if (term) {
|
ws = null
|
||||||
term.dispose()
|
}
|
||||||
term = null
|
if (term) {
|
||||||
}
|
term.dispose()
|
||||||
fitAddon = null
|
term = null
|
||||||
|
}
|
||||||
|
fitAddon = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendResize(cols: number, rows: number) {
|
function sendResize(cols: number, rows: number) {
|
||||||
@@ -154,10 +211,13 @@ onMounted(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
status.value = 'connected'
|
status.value = 'connected'
|
||||||
refreshTerminalLayout()
|
refreshTerminalLayout()
|
||||||
scheduleResize(term!.cols, term!.rows)
|
scheduleResize(term!.cols, term!.rows)
|
||||||
|
if (props.active !== false) {
|
||||||
|
startMonitorPoll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onDataDisposable = term.onData((data) => {
|
onDataDisposable = term.onData((data) => {
|
||||||
@@ -195,10 +255,15 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.active, (active) => {
|
watch(() => props.active, (active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
refreshTerminalLayout()
|
refreshTerminalLayout()
|
||||||
}
|
if (status.value === 'connected') {
|
||||||
|
startMonitorPoll()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopMonitorPoll()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -208,6 +273,70 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
|
<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
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="flex-1 min-h-0 p-4 xterm-container"
|
class="flex-1 min-h-0 p-4 xterm-container"
|
||||||
|
|||||||
Reference in New Issue
Block a user