feat(app): 添加服务端和客户端更新功能以及系统状态监控
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled

- 在App.vue中新增服务端更新模态框和相关功能
- 添加applyServerUpdate API调用和更新确认对话框
- 实现客户端版本信息显示和更新检测功能
- 添加系统状态监控包括CPU、内存和磁盘使用情况
- 新增getClientSystemStats API接口获取客户端系统统计信息
- 更新go.mod和go.sum文件添加必要的依赖包
- 优化UI界面样式和交互体验
This commit is contained in:
Flik
2026-01-22 21:32:03 +08:00
parent 7cddb7b3e7
commit 9f13b0d4e9
21 changed files with 731 additions and 232 deletions

View File

@@ -15,7 +15,7 @@ import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
getClientPluginConfig, updateClientPluginConfig,
getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin,
checkClientUpdate, applyClientUpdate, type UpdateInfo
checkClientUpdate, applyClientUpdate, getClientSystemStats, type UpdateInfo, type SystemStats
} from '../api'
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
import LogViewer from '../components/LogViewer.vue'
@@ -37,12 +37,17 @@ const clientPlugins = ref<ClientPlugin[]>([])
const loading = ref(false)
const clientOs = ref('')
const clientArch = ref('')
const clientVersion = ref('')
// 客户端更新相关
const clientUpdate = ref<UpdateInfo | null>(null)
const checkingUpdate = ref(false)
const updatingClient = ref(false)
// 系统状态相关
const systemStats = ref<SystemStats | null>(null)
const loadingStats = ref(false)
// Rule Schemas
const pluginRuleSchemas = ref<RuleSchemasMap>({})
const loadRuleSchemas = async () => {
@@ -103,6 +108,13 @@ const loadClient = async () => {
clientPlugins.value = data.plugins || []
clientOs.value = data.os || ''
clientArch.value = data.arch || ''
clientVersion.value = data.version || ''
// 如果客户端在线且有平台信息,自动检测更新
if (data.online && data.os && data.arch) {
autoCheckClientUpdate()
loadSystemStats()
}
} catch (e) {
message.error('加载客户端信息失败')
console.error(e)
@@ -111,6 +123,39 @@ const loadClient = async () => {
}
}
// 自动检测客户端更新(静默)
const autoCheckClientUpdate = async () => {
try {
const { data } = await checkClientUpdate(clientOs.value, clientArch.value)
clientUpdate.value = data
} catch (e) {
console.error('Auto check update failed', e)
}
}
// 加载系统状态
const loadSystemStats = async () => {
if (!online.value) return
loadingStats.value = true
try {
const { data } = await getClientSystemStats(clientId)
systemStats.value = data
} catch (e) {
console.error('Failed to load system stats', e)
} finally {
loadingStats.value = false
}
}
// 格式化字节大小
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 客户端更新
const handleCheckClientUpdate = async () => {
if (!online.value) {
@@ -473,11 +518,11 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<div class="page-header">
<div class="header-left">
<button class="back-btn" @click="router.push('/')">
<n-icon size="20"><ArrowBackOutline /></n-icon>
<ArrowBackOutline class="btn-icon-lg" />
</button>
<h1 class="page-title">{{ nickname || clientId }}</h1>
<button class="edit-btn" @click="openRenameModal">
<n-icon size="16"><CreateOutline /></n-icon>
<CreateOutline class="btn-icon" />
</button>
<span class="status-tag" :class="{ online }">
{{ online ? '在线' : '离线' }}
@@ -485,15 +530,15 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
</div>
<div class="header-actions">
<button v-if="online" class="glass-btn primary" @click="pushConfigToClient(clientId).then(() => message.success('已推送'))">
<n-icon size="16"><PushOutline /></n-icon>
<PushOutline class="btn-icon" />
<span>推送配置</span>
</button>
<button class="glass-btn" @click="showLogViewer=true">
<n-icon size="16"><DocumentTextOutline /></n-icon>
<DocumentTextOutline class="btn-icon" />
<span>日志</span>
</button>
<button class="glass-btn danger" @click="confirmDelete">
<n-icon size="16"><TrashOutline /></n-icon>
<TrashOutline class="btn-icon" />
<span>删除</span>
</button>
</div>
@@ -517,6 +562,15 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<span class="stat-label">远程 IP</span>
<span class="stat-value">{{ remoteAddr || '-' }}</span>
</div>
<div class="stat-item">
<span class="stat-label">客户端版本</span>
<span class="stat-value">
{{ clientVersion || '-' }}
<span v-if="clientUpdate?.available" class="update-badge" @click="handleApplyClientUpdate">
可更新
</span>
</span>
</div>
<div class="stat-item">
<span class="stat-label">最后心跳</span>
<span class="stat-value">{{ lastPing ? new Date(lastPing).toLocaleTimeString() : '-' }}</span>
@@ -545,12 +599,57 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
</div>
</div>
<!-- System Stats Card -->
<div class="glass-card" v-if="online">
<div class="card-header">
<h3>系统状态</h3>
<button class="glass-btn tiny" :disabled="loadingStats" @click="loadSystemStats">
<RefreshOutline class="btn-icon-sm" />
刷新
</button>
</div>
<div class="card-body">
<div v-if="!systemStats" class="empty-hint">
{{ loadingStats ? '加载中...' : '点击刷新获取状态' }}
</div>
<template v-else>
<div class="system-stat-item">
<span class="system-stat-label">CPU</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: systemStats.cpu_usage + '%' }"></div>
</div>
<span class="system-stat-value">{{ systemStats.cpu_usage.toFixed(1) }}%</span>
</div>
<div class="system-stat-item">
<span class="system-stat-label">内存</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: systemStats.memory_usage + '%' }"></div>
</div>
<span class="system-stat-value">{{ systemStats.memory_usage.toFixed(1) }}%</span>
</div>
<div class="system-stat-detail">
{{ formatBytes(systemStats.memory_used) }} / {{ formatBytes(systemStats.memory_total) }}
</div>
<div class="system-stat-item">
<span class="system-stat-label">磁盘</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: systemStats.disk_usage + '%' }"></div>
</div>
<span class="system-stat-value">{{ systemStats.disk_usage.toFixed(1) }}%</span>
</div>
<div class="system-stat-detail">
{{ formatBytes(systemStats.disk_used) }} / {{ formatBytes(systemStats.disk_total) }}
</div>
</template>
</div>
</div>
<!-- Update Card -->
<div class="glass-card">
<div class="card-header">
<h3>客户端更新</h3>
<button class="glass-btn tiny" :disabled="!online || checkingUpdate" @click="handleCheckClientUpdate">
<n-icon size="14"><RefreshOutline /></n-icon>
<RefreshOutline class="btn-icon-sm" />
检查
</button>
</div>
@@ -563,7 +662,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<div v-if="clientUpdate.download_url" class="update-available">
<p>发现新版本 {{ clientUpdate.latest }}</p>
<button class="glass-btn primary small" :disabled="updatingClient" @click="handleApplyClientUpdate">
<n-icon size="14"><CloudDownloadOutline /></n-icon>
<CloudDownloadOutline class="btn-icon-sm" />
更新
</button>
</div>
@@ -580,7 +679,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<div class="card-header">
<h3>代理规则</h3>
<button class="glass-btn primary small" @click="openCreateRule">
<n-icon size="14"><AddOutline /></n-icon>
<AddOutline class="btn-icon-sm" />
添加规则
</button>
</div>
@@ -757,7 +856,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<div v-for="plugin in storePlugins" :key="plugin.name" class="store-plugin-card">
<div class="store-plugin-header">
<span class="store-plugin-name">{{ plugin.name }}</span>
<n-tag size="small">v{{ plugin.version }}</n-tag>
<GlassTag>v{{ plugin.version }}</GlassTag>
</div>
<p class="store-plugin-desc">{{ plugin.description }}</p>
<button class="glass-btn primary small full" @click="handleInstallStorePlugin(plugin)">
@@ -1000,6 +1099,22 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
font-size: 12px;
}
.update-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.update-badge:hover {
background: rgba(251, 191, 36, 0.3);
}
/* Mini Stats */
.stats-row {
display: flex;
@@ -1335,6 +1450,16 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
height: 14px;
}
.btn-icon-lg {
width: 20px;
height: 20px;
}
.btn-icon-sm {
width: 14px;
height: 14px;
}
.plugin-icon {
width: 18px;
height: 18px;
@@ -1345,4 +1470,49 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
width: 16px;
height: 16px;
}
/* System Stats */
.system-stat-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.system-stat-label {
width: 40px;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
.progress-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #60a5fa, #a78bfa);
border-radius: 4px;
transition: width 0.3s ease;
}
.system-stat-value {
width: 50px;
text-align: right;
font-size: 12px;
color: white;
font-family: monospace;
}
.system-stat-detail {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
text-align: right;
margin-bottom: 12px;
margin-top: -4px;
}
</style>

View File

@@ -37,7 +37,24 @@ const loadTrafficStats = async () => {
const loadTrafficHourly = async () => {
try {
const { data } = await getTrafficHourly()
trafficHistory.value = data.records || []
const records = data.records || []
// 如果没有数据生成从当前时间开始的24小时空数据
if (records.length === 0) {
const now = new Date()
const currentHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours())
const emptyRecords: TrafficRecord[] = []
for (let i = 23; i >= 0; i--) {
const ts = new Date(currentHour.getTime() - i * 3600 * 1000)
emptyRecords.push({
timestamp: Math.floor(ts.getTime() / 1000),
inbound: 0,
outbound: 0
})
}
trafficHistory.value = emptyRecords
} else {
trafficHistory.value = records
}
} catch (e) {
console.error('Failed to load hourly traffic', e)
}
@@ -106,8 +123,8 @@ onMounted(() => {
<div class="dashboard-content">
<!-- Header -->
<div class="dashboard-header">
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
<p class="text-white/70">Monitor your tunnel connections and traffic</p>
<h1 class="text-3xl font-bold text-white mb-2">仪表盘</h1>
<p class="text-white/70">监控隧道连接和流量状态</p>
</div>
<!-- Stats Grid -->
@@ -226,9 +243,6 @@ onMounted(() => {
</div>
</div>
</div>
<div class="chart-hint" v-if="trafficHistory.length === 0">
<span>暂无流量数据</span>
</div>
</div>
</div>
</div>
@@ -411,25 +425,29 @@ onMounted(() => {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
min-height: 48px;
justify-content: center;
}
.stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
line-height: 1.2;
}
.stat-value {
font-size: 32px;
font-size: 28px;
font-weight: 700;
color: white;
line-height: 1.1;
line-height: 1.2;
}
.stat-unit {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
line-height: 1.2;
}
/* Client count special styling */

View File

@@ -1,23 +1,16 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { CloudDownloadOutline, RefreshOutline, ServerOutline, SettingsOutline, SaveOutline } from '@vicons/ionicons5'
import GlassTag from '../components/GlassTag.vue'
import { ServerOutline, SettingsOutline, SaveOutline } from '@vicons/ionicons5'
import { useToast } from '../composables/useToast'
import { useConfirm } from '../composables/useConfirm'
import {
getVersionInfo, checkServerUpdate, applyServerUpdate,
getServerConfig, updateServerConfig,
type UpdateInfo, type VersionInfo, type ServerConfigResponse
getVersionInfo, getServerConfig, updateServerConfig,
type VersionInfo, type ServerConfigResponse
} from '../api'
const message = useToast()
const dialog = useConfirm()
const versionInfo = ref<VersionInfo | null>(null)
const serverUpdate = ref<UpdateInfo | null>(null)
const loading = ref(true)
const checkingServer = ref(false)
const updatingServer = ref(false)
// 服务器配置
const serverConfig = ref<ServerConfigResponse | null>(null)
@@ -96,58 +89,6 @@ const handleSaveConfig = async () => {
}
}
const handleCheckServerUpdate = async () => {
checkingServer.value = true
try {
const { data } = await checkServerUpdate()
serverUpdate.value = data
if (data.available) {
message.success('发现新版本: ' + data.latest)
} else {
message.info('已是最新版本')
}
} catch (e: any) {
message.error(e.response?.data || '检查更新失败')
} finally {
checkingServer.value = false
}
}
const handleApplyServerUpdate = () => {
if (!serverUpdate.value?.download_url) {
message.error('没有可用的下载链接')
return
}
dialog.warning({
title: '确认更新服务端',
content: `即将更新服务端到 ${serverUpdate.value.latest},更新后服务器将自动重启。确定要继续吗?`,
positiveText: '更新并重启',
negativeText: '取消',
onPositiveClick: async () => {
updatingServer.value = true
try {
await applyServerUpdate(serverUpdate.value!.download_url)
message.success('更新已开始,服务器将在几秒后重启')
setTimeout(() => {
window.location.reload()
}, 5000)
} catch (e: any) {
message.error(e.response?.data || '更新失败')
updatingServer.value = false
}
}
})
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
onMounted(() => {
loadVersionInfo()
loadServerConfig()
@@ -301,50 +242,6 @@ onMounted(() => {
<div v-else class="empty-state">无法加载配置信息</div>
</div>
</div>
<!-- Server Update Card -->
<div class="glass-card">
<div class="card-header">
<h3>服务端更新</h3>
<button class="glass-btn small" :disabled="checkingServer" @click="handleCheckServerUpdate">
<RefreshOutline class="btn-icon" />
检查更新
</button>
</div>
<div class="card-body">
<div v-if="!serverUpdate" class="empty-state">
点击检查更新按钮查看是否有新版本
</div>
<template v-else>
<div v-if="serverUpdate.available" class="update-alert success">
发现新版本 {{ serverUpdate.latest }}当前版本 {{ serverUpdate.current }}
</div>
<div v-else class="update-alert info">
当前已是最新版本 {{ serverUpdate.current }}
</div>
<div v-if="serverUpdate.download_url" class="download-info">
下载文件: {{ serverUpdate.asset_name }}
<GlassTag style="margin-left: 8px;">{{ formatBytes(serverUpdate.asset_size) }}</GlassTag>
</div>
<div v-if="serverUpdate.release_note" class="release-note">
<span class="note-label">更新日志:</span>
<pre>{{ serverUpdate.release_note }}</pre>
</div>
<button
v-if="serverUpdate.available && serverUpdate.download_url"
class="glass-btn primary"
:disabled="updatingServer"
@click="handleApplyServerUpdate"
>
<CloudDownloadOutline class="btn-icon" />
下载并更新服务端
</button>
</template>
</div>
</div>
</div>
</div>
</template>