feat: implement precise worker status tracking and monitoring UI
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/engigu/baihu-panel/internal/services/tasks"
|
||||||
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MonitorController struct {
|
||||||
|
executorService *tasks.ExecutorService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMonitorController(executorService *tasks.ExecutorService) *MonitorController {
|
||||||
|
return &MonitorController{
|
||||||
|
executorService: executorService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var monitorUpgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // 开发环境允许所有跨域,生产环境可根据配置限制
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemMonitor 获取系统和内存监控信息 (HTTP)
|
||||||
|
func (mc *MonitorController) GetSystemMonitor(c *gin.Context) {
|
||||||
|
data := mc.getMonitorData()
|
||||||
|
utils.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorWS WebSocket实时推送系统监控信息
|
||||||
|
func (mc *MonitorController) MonitorWS(c *gin.Context) {
|
||||||
|
ws, err := monitorUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// 先立即发送一次
|
||||||
|
mc.sendMonitorData(ws)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := mc.sendMonitorData(ws); err != nil {
|
||||||
|
return // 客户端断开连接或发送失败
|
||||||
|
}
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mc *MonitorController) sendMonitorData(ws *websocket.Conn) error {
|
||||||
|
data := mc.getMonitorData()
|
||||||
|
return ws.WriteJSON(gin.H{
|
||||||
|
"code": 200,
|
||||||
|
"data": data,
|
||||||
|
"msg": "success",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mc *MonitorController) getMonitorData() gin.H {
|
||||||
|
var m runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
|
||||||
|
return gin.H{
|
||||||
|
"env": gin.H{
|
||||||
|
"os": runtime.GOOS,
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"go_version": runtime.Version(),
|
||||||
|
"num_cpu": runtime.NumCPU(),
|
||||||
|
"goroutines": runtime.NumGoroutine(),
|
||||||
|
},
|
||||||
|
"mem": gin.H{
|
||||||
|
"alloc": m.Alloc,
|
||||||
|
"total_alloc": m.TotalAlloc,
|
||||||
|
"sys": m.Sys,
|
||||||
|
"lookups": m.Lookups,
|
||||||
|
"mallocs": m.Mallocs,
|
||||||
|
"frees": m.Frees,
|
||||||
|
},
|
||||||
|
"heap": gin.H{
|
||||||
|
"heap_alloc": m.HeapAlloc,
|
||||||
|
"heap_sys": m.HeapSys,
|
||||||
|
"heap_idle": m.HeapIdle,
|
||||||
|
"heap_inuse": m.HeapInuse,
|
||||||
|
"heap_released": m.HeapReleased,
|
||||||
|
"heap_objects": m.HeapObjects,
|
||||||
|
},
|
||||||
|
"gc": gin.H{
|
||||||
|
"next_gc": m.NextGC,
|
||||||
|
"last_gc": m.LastGC,
|
||||||
|
"pause_total_ns": m.PauseTotalNs,
|
||||||
|
"num_gc": m.NumGC,
|
||||||
|
},
|
||||||
|
"scheduler": gin.H{
|
||||||
|
"scheduled": mc.executorService.GetScheduledCount(),
|
||||||
|
"running": mc.executorService.GetRunningCount(),
|
||||||
|
"queue_size": mc.executorService.GetScheduler().GetQueueSize(),
|
||||||
|
"worker_count": mc.executorService.GetScheduler().GetConfig().WorkerCount,
|
||||||
|
"workers": mc.executorService.GetScheduler().GetWorkerStatuses(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,6 +174,16 @@ func (h *schedulerHooksAdapter) OnHeartbeat(ctx context.Context, logID string, d
|
|||||||
// TaskExecutor 定义任务执行函数签名
|
// TaskExecutor 定义任务执行函数签名
|
||||||
type TaskExecutor func(ctx context.Context, req *ExecutionRequest, stdout, stderr io.Writer) (*Result, error)
|
type TaskExecutor func(ctx context.Context, req *ExecutionRequest, stdout, stderr io.Writer) (*Result, error)
|
||||||
|
|
||||||
|
// WorkerStatus 定义并发池中单个 Worker 的状态
|
||||||
|
type WorkerStatus struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Status string `json:"status"` // 状态: "idle" 或 "running"
|
||||||
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
TaskName string `json:"task_name,omitempty"`
|
||||||
|
StartTime int64 `json:"start_time,omitempty"` // 开始时间戳 (秒)
|
||||||
|
Duration int64 `json:"duration,omitempty"` // 已运行时长 (秒)
|
||||||
|
}
|
||||||
|
|
||||||
// Scheduler 统一调度器(独立组件,可在主服务和 Agent 中复用)
|
// Scheduler 统一调度器(独立组件,可在主服务和 Agent 中复用)
|
||||||
// 调度器本身只负责队列管理和任务调度,具体的执行逻辑和事件处理由 Handler 实现
|
// 调度器本身只负责队列管理和任务调度,具体的执行逻辑和事件处理由 Handler 实现
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
@@ -188,6 +198,9 @@ type Scheduler struct {
|
|||||||
logger SchedulerLogger
|
logger SchedulerLogger
|
||||||
runningTasks map[string]context.CancelFunc // 记录运行中的任务,用于停止 (TaskID -> CancelFunc)
|
runningTasks map[string]context.CancelFunc // 记录运行中的任务,用于停止 (TaskID -> CancelFunc)
|
||||||
runningExecs map[string]context.CancelFunc // 记录运行中的执行,用于停止 (LogID -> CancelFunc)
|
runningExecs map[string]context.CancelFunc // 记录运行中的执行,用于停止 (LogID -> CancelFunc)
|
||||||
|
|
||||||
|
workers []WorkerStatus
|
||||||
|
workerMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScheduler 创建调度器
|
// NewScheduler 创建调度器
|
||||||
@@ -224,6 +237,14 @@ func NewScheduler(config SchedulerConfig, handler SchedulerEventHandler) *Schedu
|
|||||||
logger: &DefaultLogger{},
|
logger: &DefaultLogger{},
|
||||||
runningTasks: make(map[string]context.CancelFunc),
|
runningTasks: make(map[string]context.CancelFunc),
|
||||||
runningExecs: make(map[string]context.CancelFunc),
|
runningExecs: make(map[string]context.CancelFunc),
|
||||||
|
workers: make([]WorkerStatus, config.WorkerCount),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < config.WorkerCount; i++ {
|
||||||
|
s.workers[i] = WorkerStatus{
|
||||||
|
ID: i,
|
||||||
|
Status: "idle",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
@@ -332,7 +353,32 @@ func (s *Scheduler) worker(id int) {
|
|||||||
}()
|
}()
|
||||||
// 速率限制
|
// 速率限制
|
||||||
<-s.rateLimiter
|
<-s.rateLimiter
|
||||||
s.executeTask(req)
|
|
||||||
|
func() {
|
||||||
|
// 恢复 worker 状态为空闲
|
||||||
|
defer func() {
|
||||||
|
s.workerMu.Lock()
|
||||||
|
if id >= 0 && id < len(s.workers) {
|
||||||
|
s.workers[id].Status = "idle"
|
||||||
|
s.workers[id].TaskID = ""
|
||||||
|
s.workers[id].TaskName = ""
|
||||||
|
s.workers[id].StartTime = 0
|
||||||
|
}
|
||||||
|
s.workerMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 更新 worker 状态为运行中
|
||||||
|
s.workerMu.Lock()
|
||||||
|
if id >= 0 && id < len(s.workers) {
|
||||||
|
s.workers[id].Status = "running"
|
||||||
|
s.workers[id].TaskID = req.TaskID
|
||||||
|
s.workers[id].TaskName = req.Name
|
||||||
|
s.workers[id].StartTime = time.Now().Unix()
|
||||||
|
}
|
||||||
|
s.workerMu.Unlock()
|
||||||
|
|
||||||
|
s.executeTask(req)
|
||||||
|
}()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,3 +661,24 @@ func (s *Scheduler) GetConfig() SchedulerConfig {
|
|||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
return s.config
|
return s.config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWorkerStatuses 获取所有 Worker 的状态
|
||||||
|
func (s *Scheduler) GetWorkerStatuses() []WorkerStatus {
|
||||||
|
s.workerMu.RLock()
|
||||||
|
defer s.workerMu.RUnlock()
|
||||||
|
// 返回副本防止外部修改
|
||||||
|
statuses := make([]WorkerStatus, len(s.workers))
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for i, w := range s.workers {
|
||||||
|
statuses[i] = w
|
||||||
|
// 在服务端计算运行时间,彻底避免客户端与服务端时钟不一致导致的计算偏差
|
||||||
|
if w.Status == "running" && w.StartTime > 0 {
|
||||||
|
duration := now - w.StartTime
|
||||||
|
if duration < 0 {
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
|
statuses[i].Duration = duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func initAuthorizedAPIRoutes(api *gin.RouterGroup, c *Controllers) {
|
|||||||
registerAppLogRoutes(adminOnly, c)
|
registerAppLogRoutes(adminOnly, c)
|
||||||
registerSystemWSRoutes(adminOnly, c)
|
registerSystemWSRoutes(adminOnly, c)
|
||||||
registerWebUIRoutes(adminOnly, c)
|
registerWebUIRoutes(adminOnly, c)
|
||||||
|
registerMonitorRoutes(adminOnly, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +269,14 @@ func registerSystemWSRoutes(g *gin.RouterGroup, c *Controllers) {
|
|||||||
g.GET("/ws/events", c.SystemWS.HandleEvents)
|
g.GET("/ws/events", c.SystemWS.HandleEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerMonitorRoutes(g *gin.RouterGroup, c *Controllers) {
|
||||||
|
monitor := g.Group("/monitor")
|
||||||
|
{
|
||||||
|
monitor.GET("", c.Monitor.GetSystemMonitor)
|
||||||
|
monitor.GET("/ws", c.Monitor.MonitorWS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func initAgentAPIRoutes(root *gin.RouterGroup, c *Controllers) {
|
func initAgentAPIRoutes(root *gin.RouterGroup, c *Controllers) {
|
||||||
// Agent API(供远程 Agent 调用,不使用 /v1 版本号)
|
// Agent API(供远程 Agent 调用,不使用 /v1 版本号)
|
||||||
agentAPI := root.Group("/api/agent")
|
agentAPI := root.Group("/api/agent")
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func RegisterControllers() *Controllers {
|
|||||||
AppLog: controllers.NewAppLogController(),
|
AppLog: controllers.NewAppLogController(),
|
||||||
SystemWS: controllers.NewSystemWSController(),
|
SystemWS: controllers.NewSystemWSController(),
|
||||||
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
|
WebUI: controllers.NewWebUIController(services.NewWebUIService(settingsService)),
|
||||||
|
Monitor: controllers.NewMonitorController(executorService),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type Controllers struct {
|
|||||||
AppLog *controllers.AppLogController
|
AppLog *controllers.AppLogController
|
||||||
SystemWS *controllers.SystemWSController
|
SystemWS *controllers.SystemWSController
|
||||||
WebUI *controllers.WebUIController
|
WebUI *controllers.WebUIController
|
||||||
|
Monitor *controllers.MonitorController
|
||||||
}
|
}
|
||||||
|
|
||||||
func Setup(c *Controllers) *gin.Engine {
|
func Setup(c *Controllers) *gin.Engine {
|
||||||
|
|||||||
@@ -9,6 +9,52 @@ interface ApiResponse<T> {
|
|||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MonitorStats {
|
||||||
|
env: {
|
||||||
|
os: string
|
||||||
|
arch: string
|
||||||
|
go_version: string
|
||||||
|
num_cpu: number
|
||||||
|
goroutines: number
|
||||||
|
}
|
||||||
|
mem: {
|
||||||
|
alloc: number
|
||||||
|
total_alloc: number
|
||||||
|
sys: number
|
||||||
|
lookups: number
|
||||||
|
mallocs: number
|
||||||
|
frees: number
|
||||||
|
}
|
||||||
|
heap: {
|
||||||
|
heap_alloc: number
|
||||||
|
heap_sys: number
|
||||||
|
heap_idle: number
|
||||||
|
heap_inuse: number
|
||||||
|
heap_released: number
|
||||||
|
heap_objects: number
|
||||||
|
}
|
||||||
|
gc: {
|
||||||
|
next_gc: number
|
||||||
|
last_gc: number
|
||||||
|
pause_total_ns: number
|
||||||
|
num_gc: number
|
||||||
|
}
|
||||||
|
scheduler: {
|
||||||
|
scheduled: number
|
||||||
|
running: number
|
||||||
|
queue_size: number
|
||||||
|
worker_count: number
|
||||||
|
workers: {
|
||||||
|
id: number
|
||||||
|
status: string
|
||||||
|
task_id?: string
|
||||||
|
task_name?: string
|
||||||
|
start_time?: number
|
||||||
|
duration?: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE_URL}${url}`, {
|
const res = await fetch(`${API_BASE_URL}${url}`, {
|
||||||
...options,
|
...options,
|
||||||
@@ -145,6 +191,7 @@ export const api = {
|
|||||||
taskStats: (days?: number) => request<TaskStatsItem[]>(`/taskstats${days ? `?days=${days}` : ''}`)
|
taskStats: (days?: number) => request<TaskStatsItem[]>(`/taskstats${days ? `?days=${days}` : ''}`)
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
getMonitor: () => request<MonitorStats>('/monitor'),
|
||||||
changePassword: (data: { old_username?: string; username?: string; old_password: string; new_password?: string }) =>
|
changePassword: (data: { old_username?: string; username?: string; old_password: string; new_password?: string }) =>
|
||||||
request('/settings/password', { method: 'POST', body: JSON.stringify(data) }),
|
request('/settings/password', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
getSite: () => request<SiteSettings>('/settings/site'),
|
getSite: () => request<SiteSettings>('/settings/site'),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
import { resetAuthCache } from '@/router'
|
import { resetAuthCache } from '@/router'
|
||||||
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe, Bell } from 'lucide-vue-next'
|
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe, Bell, Activity } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||||
import SystemNotice from '@/components/SystemNotice.vue'
|
import SystemNotice from '@/components/SystemNotice.vue'
|
||||||
@@ -63,6 +63,7 @@ const navItems = [
|
|||||||
{ to: '/terminal', icon: Terminal, label: '终端命令', exact: true },
|
{ to: '/terminal', icon: Terminal, label: '终端命令', exact: true },
|
||||||
{ to: '/notify', icon: Bell, label: '消息推送', exact: true },
|
{ to: '/notify', icon: Bell, label: '消息推送', exact: true },
|
||||||
{ to: '/logs', icon: KeyRound, label: '运行日志', exact: true },
|
{ to: '/logs', icon: KeyRound, label: '运行日志', exact: true },
|
||||||
|
{ to: '/monitor', icon: Activity, label: '系统监控', exact: true },
|
||||||
{ to: '/settings', icon: Settings, label: '系统设置', exact: true },
|
{ to: '/settings', icon: Settings, label: '系统设置', exact: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const router = createRouter({
|
|||||||
{ path: 'logs', name: 'logs', component: () => import('@/views/logs/MessageLogs.vue') },
|
{ path: 'logs', name: 'logs', component: () => import('@/views/logs/MessageLogs.vue') },
|
||||||
{ path: 'terminal', name: 'terminal', component: () => import('@/views/terminal/Terminal.vue') },
|
{ path: 'terminal', name: 'terminal', component: () => import('@/views/terminal/Terminal.vue') },
|
||||||
{ path: 'notify', name: 'notify', component: () => import('@/views/notify/Notify.vue') },
|
{ path: 'notify', name: 'notify', component: () => import('@/views/notify/Notify.vue') },
|
||||||
|
{ path: 'monitor', name: 'monitor', component: () => import('@/views/monitor/Monitor.vue') },
|
||||||
{ path: 'settings', name: 'settings', component: () => import('@/views/settings/Settings.vue') }
|
{ path: 'settings', name: 'settings', component: () => import('@/views/settings/Settings.vue') }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
|
import type { MonitorStats } from '@/api'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { RefreshCw } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js'
|
||||||
|
import { Line, Bar } from 'vue-chartjs'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeTab = ref('charts')
|
||||||
|
const stats = ref<MonitorStats | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
let timer: any = null
|
||||||
|
|
||||||
|
// --- 时序数据池 ---
|
||||||
|
const historySize = 60 // 保存最近60次请求(约3分钟@3s)
|
||||||
|
const timeLabels = ref<string[]>([])
|
||||||
|
const goroutinesData = ref<number[]>([])
|
||||||
|
const allocData = ref<number[]>([])
|
||||||
|
const sysData = ref<number[]>([])
|
||||||
|
const gcPausesData = ref<number[]>([])
|
||||||
|
const scheduledData = ref<number[]>([])
|
||||||
|
const runningData = ref<number[]>([])
|
||||||
|
const queueData = ref<number[]>([])
|
||||||
|
let lastPauseNs = 0
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const baseUrl = (window as any).__BASE_URL__ || ''
|
||||||
|
const apiVersion = (window as any).__API_VERSION__ || '/api/v1'
|
||||||
|
|
||||||
|
const connectWS = () => {
|
||||||
|
if (timer) return
|
||||||
|
loading.value = true
|
||||||
|
const host = window.location.host
|
||||||
|
const wsUrl = `${protocol}//${host}${baseUrl}${apiVersion}/monitor/ws`
|
||||||
|
|
||||||
|
timer = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
timer.onopen = () => {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
if (payload.code === 200 && payload.data) {
|
||||||
|
const res = payload.data
|
||||||
|
stats.value = res
|
||||||
|
|
||||||
|
const nowStr = new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||||
|
if (timeLabels.value.length >= historySize) {
|
||||||
|
timeLabels.value.shift()
|
||||||
|
goroutinesData.value.shift()
|
||||||
|
allocData.value.shift()
|
||||||
|
sysData.value.shift()
|
||||||
|
gcPausesData.value.shift()
|
||||||
|
scheduledData.value.shift()
|
||||||
|
runningData.value.shift()
|
||||||
|
queueData.value.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
timeLabels.value.push(nowStr)
|
||||||
|
goroutinesData.value.push(res.env.goroutines)
|
||||||
|
allocData.value.push(Number((res.mem.alloc / 1024 / 1024).toFixed(2)))
|
||||||
|
sysData.value.push(Number((res.mem.sys / 1024 / 1024).toFixed(2)))
|
||||||
|
|
||||||
|
let pauseDelta = 0
|
||||||
|
if (lastPauseNs > 0 && res.gc.pause_total_ns >= lastPauseNs) {
|
||||||
|
pauseDelta = Number(((res.gc.pause_total_ns - lastPauseNs) / 1000000).toFixed(2))
|
||||||
|
}
|
||||||
|
lastPauseNs = res.gc.pause_total_ns
|
||||||
|
gcPausesData.value.push(pauseDelta)
|
||||||
|
scheduledData.value.push(res.scheduler.scheduled)
|
||||||
|
runningData.value.push(res.scheduler.running)
|
||||||
|
queueData.value.push(res.scheduler.queue_size)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Parse WS message error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.onclose = () => {
|
||||||
|
loading.value = false
|
||||||
|
timer = null
|
||||||
|
// 断线后2秒自动重连
|
||||||
|
setTimeout(connectWS, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.onerror = () => {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnectWS = () => {
|
||||||
|
if (timer) {
|
||||||
|
// 置空 onclose 避免触发自动重连
|
||||||
|
timer.onclose = null
|
||||||
|
timer.close()
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
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 formatNs = (ns: number) => {
|
||||||
|
if (ns < 1000) return ns + ' ns'
|
||||||
|
if (ns < 1000000) return (ns / 1000).toFixed(2) + ' μs'
|
||||||
|
return (ns / 1000000).toFixed(2) + ' ms'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
connectWS()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnectWS()
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 图表配置 ---
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: {
|
||||||
|
duration: 0 // 关闭过渡动画以支持实时流畅更新
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'index' as const,
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top' as const,
|
||||||
|
labels: {
|
||||||
|
usePointStyle: false,
|
||||||
|
boxWidth: 12,
|
||||||
|
boxHeight: 12,
|
||||||
|
useBorderRadius: true,
|
||||||
|
borderRadius: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
border: { dash: [4, 4] },
|
||||||
|
grid: { color: 'rgba(0, 0, 0, 0.05)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goroutineChartData = computed(() => ({
|
||||||
|
labels: [...timeLabels.value],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Goroutines (协程并发数)',
|
||||||
|
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||||
|
borderColor: '#2563eb', // blue-600
|
||||||
|
borderWidth: 2,
|
||||||
|
data: [...goroutinesData.value],
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHitRadius: 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const memChartData = computed(() => ({
|
||||||
|
labels: [...timeLabels.value],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Alloc (MB) 当前分配',
|
||||||
|
backgroundColor: 'rgba(5, 150, 105, 0.1)',
|
||||||
|
borderColor: '#059669', // emerald-600
|
||||||
|
borderWidth: 2,
|
||||||
|
data: [...allocData.value],
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHitRadius: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sys (MB) 系统申请上限',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: '#d97706', // amber-600
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
data: [...sysData.value],
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHitRadius: 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const gcChartData = computed(() => ({
|
||||||
|
labels: [...timeLabels.value],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'GC Pause (ms) 垃圾回收停顿时间',
|
||||||
|
backgroundColor: '#ea580c', // orange-600
|
||||||
|
data: [...gcPausesData.value],
|
||||||
|
borderRadius: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const schedulerChartData = computed(() => ({
|
||||||
|
labels: [...timeLabels.value],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '正在运行 (Running)',
|
||||||
|
backgroundColor: 'rgba(16, 185, 129, 0.1)', // emerald-500
|
||||||
|
borderColor: '#10b981',
|
||||||
|
borderWidth: 2,
|
||||||
|
data: [...runningData.value],
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHitRadius: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '调度中 (Scheduled)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: '#6366f1', // indigo-500
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
data: [...scheduledData.value],
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHitRadius: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '排队积压 (Queue)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderColor: '#f59e0b', // amber-500
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [2, 2],
|
||||||
|
data: [...queueData.value],
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHitRadius: 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Tabs v-model="activeTab" class="space-y-6 w-full">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">系统监控</h2>
|
||||||
|
<p class="text-muted-foreground text-sm">实时监控面板资源、内存分配和垃圾回收状态</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<TabsList class="grid flex-1 sm:flex-none sm:w-[240px] grid-cols-2 h-9 p-1">
|
||||||
|
<TabsTrigger value="charts" class="text-xs sm:text-sm">实时图表</TabsTrigger>
|
||||||
|
<TabsTrigger value="dashboard" class="text-xs sm:text-sm">数据视图</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="() => { disconnectWS(); connectWS() }" :disabled="loading" title="刷新并重连">
|
||||||
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="dashboard" class="mt-0 space-y-4">
|
||||||
|
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base text-blue-600">执行环境</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">Go 版本</dt><dd class="font-medium">{{ stats.env.go_version }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">系统 / 架构</dt><dd class="font-medium">{{ stats.env.os }} / {{ stats.env.arch }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">逻辑 CPU 数量</dt><dd class="font-medium">{{ stats.env.num_cpu }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">当前协程数 (Goroutines)</dt><dd class="font-bold text-blue-600">{{ stats.env.goroutines }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base text-indigo-600">任务调度</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">驻留常驻任务 (Scheduled)</dt><dd class="font-medium text-indigo-600">{{ stats.scheduler.scheduled }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">当前运行中 (Running)</dt><dd class="font-bold text-emerald-600">{{ stats.scheduler.running }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">排队积压 (Queue Size)</dt><dd class="font-bold text-amber-500">{{ stats.scheduler.queue_size }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">并发池限制 (Worker Count)</dt><dd class="font-medium">{{ stats.scheduler.worker_count }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div v-if="stats && stats.scheduler.workers" class="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base text-amber-600">并发池 Worker 状态</CardTitle>
|
||||||
|
<CardDescription>精确监控底层协程池调度执行情况</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-2">
|
||||||
|
<div v-for="worker in stats.scheduler.workers" :key="worker.id" class="border rounded-md p-3 flex flex-col justify-between" :class="[worker.status === 'running' ? 'bg-amber-50 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/50' : 'bg-green-50 border-green-200 dark:bg-green-950/20 dark:border-green-900/50']">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="font-semibold text-sm">Worker #{{ worker.id }}</span>
|
||||||
|
<span v-if="worker.status === 'idle'" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">
|
||||||
|
🟢 空闲 (Idle)
|
||||||
|
</span>
|
||||||
|
<span v-else class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||||
|
⚡ 执行中 (Running)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="worker.status === 'running'" class="text-xs text-muted-foreground mt-1 space-y-1">
|
||||||
|
<div class="truncate" :title="worker.task_name"><span class="font-medium text-foreground">任务:</span> {{ worker.task_name || worker.task_id }}</div>
|
||||||
|
<div><span class="font-medium text-foreground">运行时间:</span> {{ worker.duration || 0 }}s</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-muted-foreground mt-1">
|
||||||
|
当前暂无任务分配
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base text-emerald-600">内存概览</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">当前分配内存 (Alloc)</dt><dd class="font-bold text-emerald-600">{{ formatBytes(stats.mem.alloc) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">累计分配内存 (TotalAlloc)</dt><dd class="font-medium">{{ formatBytes(stats.mem.total_alloc) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">向系统获取内存 (Sys)</dt><dd class="font-medium">{{ formatBytes(stats.mem.sys) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">内存分配次数 (Mallocs)</dt><dd class="font-medium">{{ stats.mem.mallocs }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">内存释放次数 (Frees)</dt><dd class="font-medium">{{ stats.mem.frees }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">指针查找次数 (Lookups)</dt><dd class="font-medium">{{ stats.mem.lookups }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base text-purple-600">堆栈明细</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">堆分配内存 (HeapAlloc)</dt><dd class="font-medium">{{ formatBytes(stats.heap.heap_alloc) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">堆系统内存 (HeapSys)</dt><dd class="font-medium">{{ formatBytes(stats.heap.heap_sys) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">空闲堆内存 (HeapIdle)</dt><dd class="font-medium">{{ formatBytes(stats.heap.heap_idle) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">使用中堆内存 (HeapInuse)</dt><dd class="font-medium">{{ formatBytes(stats.heap.heap_inuse) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">已释放堆内存 (HeapReleased)</dt><dd class="font-medium">{{ formatBytes(stats.heap.heap_released) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">堆对象数量 (HeapObjects)</dt><dd class="font-medium">{{ stats.heap.heap_objects }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle class="text-base text-orange-600">垃圾回收 (GC)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">下次 GC 目标 (NextGC)</dt><dd class="font-medium">{{ formatBytes(stats.gc.next_gc) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">上次 GC 时间 (LastGC)</dt><dd class="font-medium">{{ stats.gc.last_gc ? new Date(stats.gc.last_gc / 1000000).toLocaleString() : '-' }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">GC 总停顿时间 (PauseTotalNs)</dt><dd class="font-medium">{{ formatNs(stats.gc.pause_total_ns) }}</dd></div>
|
||||||
|
<div class="flex justify-between border-b border-border/50 pb-1"><dt class="text-muted-foreground">执行次数 (NumGC)</dt><dd class="font-medium">{{ stats.gc.num_gc }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="charts" class="mt-0 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<!-- Scheduler 图表 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="py-4">
|
||||||
|
<CardTitle class="text-base text-indigo-600">任务调度 (Scheduler)</CardTitle>
|
||||||
|
<CardDescription>跟踪当前节点上正在运行和调度的任务数量</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="h-64 w-full">
|
||||||
|
<Line :data="schedulerChartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Goroutines 图表 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="py-4">
|
||||||
|
<CardTitle class="text-base text-blue-600">并发协程 (Goroutines)</CardTitle>
|
||||||
|
<CardDescription>跟踪系统中轻量级线程的创建与销毁情况</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="h-64 w-full">
|
||||||
|
<Line :data="goroutineChartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Memory 图表 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="py-4">
|
||||||
|
<CardTitle class="text-base text-emerald-600">内存分配 (Memory Alloc vs Sys)</CardTitle>
|
||||||
|
<CardDescription>真实使用的内存 (Alloc) 与向系统申请的内存上限 (Sys)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="h-64 w-full">
|
||||||
|
<Line :data="memChartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- GC 图表 -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="py-4">
|
||||||
|
<CardTitle class="text-base text-orange-600">垃圾回收停顿 (GC Pauses)</CardTitle>
|
||||||
|
<CardDescription>每次探针周期内发生的垃圾回收暂停总耗时(毫秒)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="h-64 w-full">
|
||||||
|
<Bar :data="gcChartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user