diff --git a/api/nginx/router.go b/api/nginx/router.go index d7c2e798..27a95f78 100644 --- a/api/nginx/router.go +++ b/api/nginx/router.go @@ -13,6 +13,10 @@ func InitRouter(r *gin.RouterGroup) { r.POST("nginx/restart", Restart) r.POST("nginx/test", Test) r.GET("nginx/status", Status) + // 获取 Nginx 详细状态信息,包括连接数、进程信息等(Issue #850) + r.GET("nginx/detailed_status", GetDetailedStatus) + // 使用SSE推送Nginx详细状态信息 + r.GET("nginx/detailed_status/stream", StreamDetailedStatus) r.POST("nginx_log", nginx_log.GetNginxLogPage) r.GET("nginx/directives", GetDirectives) } diff --git a/api/nginx/status.go b/api/nginx/status.go new file mode 100644 index 00000000..0e70ca5c --- /dev/null +++ b/api/nginx/status.go @@ -0,0 +1,452 @@ +// GetDetailedStatus API 实现 +// 该功能用于解决 Issue #850,提供类似宝塔面板的 Nginx 负载监控功能 +// 返回详细的 Nginx 状态信息,包括请求统计、连接数、工作进程等数据 +package nginx + +import ( + "fmt" + "io" + "math" + "net/http" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/0xJacky/Nginx-UI/internal/nginx" + "github.com/gin-gonic/gin" + "github.com/shirou/gopsutil/v4/process" + "github.com/uozi-tech/cosy/logger" +) + +// NginxPerformanceInfo 存储 Nginx 性能相关信息 +type NginxPerformanceInfo struct { + // 基本状态信息 + Active int `json:"active"` // 活动连接数 + Accepts int `json:"accepts"` // 总握手次数 + Handled int `json:"handled"` // 总连接次数 + Requests int `json:"requests"` // 总请求数 + Reading int `json:"reading"` // 读取客户端请求数 + Writing int `json:"writing"` // 响应数 + Waiting int `json:"waiting"` // 驻留进程(等待请求) + + // 进程相关信息 + Workers int `json:"workers"` // 工作进程数 + Master int `json:"master"` // 主进程数 + Cache int `json:"cache"` // 缓存管理进程数 + Other int `json:"other"` // 其他Nginx相关进程数 + CPUUsage float64 `json:"cpu_usage"` // CPU 使用率 + MemoryUsage float64 `json:"memory_usage"` // 内存使用率(MB) + + // 配置信息 + WorkerProcesses int `json:"worker_processes"` // worker_processes 配置 + WorkerConnections int `json:"worker_connections"` // worker_connections 配置 +} + +// GetDetailedStatus 获取 Nginx 详细状态信息 +func GetDetailedStatus(c *gin.Context) { + // 检查 Nginx 是否运行 + pidPath := nginx.GetPIDPath() + running := true + if fileInfo, err := os.Stat(pidPath); err != nil || fileInfo.Size() == 0 { + running = false + c.JSON(http.StatusOK, gin.H{ + "running": false, + "message": "Nginx is not running", + }) + return + } + + // 获取 stub_status 模块数据 + stubStatusInfo, err := getStubStatusInfo() + if err != nil { + logger.Warn("Failed to get stub_status info:", err) + } + + // 获取进程信息 + processInfo, err := getNginxProcessInfo() + if err != nil { + logger.Warn("Failed to get process info:", err) + } + + // 获取配置信息 + configInfo, err := getNginxConfigInfo() + if err != nil { + logger.Warn("Failed to get config info:", err) + } + + // 组合所有信息 + info := NginxPerformanceInfo{ + Active: stubStatusInfo["active"], + Accepts: stubStatusInfo["accepts"], + Handled: stubStatusInfo["handled"], + Requests: stubStatusInfo["requests"], + Reading: stubStatusInfo["reading"], + Writing: stubStatusInfo["writing"], + Waiting: stubStatusInfo["waiting"], + Workers: processInfo["workers"].(int), + Master: processInfo["master"].(int), + Cache: processInfo["cache"].(int), + Other: processInfo["other"].(int), + CPUUsage: processInfo["cpu_usage"].(float64), + MemoryUsage: processInfo["memory_usage"].(float64), + WorkerProcesses: configInfo["worker_processes"], + WorkerConnections: configInfo["worker_connections"], + } + + c.JSON(http.StatusOK, gin.H{ + "running": running, + "info": info, + }) +} + +// StreamDetailedStatus 使用 SSE 流式推送 Nginx 详细状态信息 +func StreamDetailedStatus(c *gin.Context) { + // 设置 SSE 的响应头 + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + + // 创建上下文,当客户端断开连接时取消 + ctx := c.Request.Context() + + // 为防止 goroutine 泄漏,创建一个计时器通道 + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + // 立即发送一次初始数据 + sendPerformanceData(c) + + // 使用 goroutine 定期发送数据 + for { + select { + case <-ticker.C: + // 发送性能数据 + if err := sendPerformanceData(c); err != nil { + logger.Warn("Error sending SSE data:", err) + return + } + case <-ctx.Done(): + // 客户端断开连接或请求被取消 + logger.Debug("Client closed connection") + return + } + } +} + +// sendPerformanceData 发送一次性能数据 +func sendPerformanceData(c *gin.Context) error { + // 检查 Nginx 是否运行 + pidPath := nginx.GetPIDPath() + running := true + if fileInfo, err := os.Stat(pidPath); err != nil || fileInfo.Size() == 0 { + running = false + // 发送 Nginx 未运行的状态 + c.SSEvent("message", gin.H{ + "running": false, + "message": "Nginx is not running", + }) + // 刷新缓冲区,确保数据立即发送 + c.Writer.Flush() + return nil + } + + // 获取性能数据 + stubStatusInfo, err := getStubStatusInfo() + if err != nil { + logger.Warn("Failed to get stub_status info:", err) + } + + processInfo, err := getNginxProcessInfo() + if err != nil { + logger.Warn("Failed to get process info:", err) + } + + configInfo, err := getNginxConfigInfo() + if err != nil { + logger.Warn("Failed to get config info:", err) + } + + // 组合所有信息 + info := NginxPerformanceInfo{ + Active: stubStatusInfo["active"], + Accepts: stubStatusInfo["accepts"], + Handled: stubStatusInfo["handled"], + Requests: stubStatusInfo["requests"], + Reading: stubStatusInfo["reading"], + Writing: stubStatusInfo["writing"], + Waiting: stubStatusInfo["waiting"], + Workers: processInfo["workers"].(int), + Master: processInfo["master"].(int), + Cache: processInfo["cache"].(int), + Other: processInfo["other"].(int), + CPUUsage: processInfo["cpu_usage"].(float64), + MemoryUsage: processInfo["memory_usage"].(float64), + WorkerProcesses: configInfo["worker_processes"], + WorkerConnections: configInfo["worker_connections"], + } + + // 发送 SSE 事件 + c.SSEvent("message", gin.H{ + "running": running, + "info": info, + }) + + // 刷新缓冲区,确保数据立即发送 + c.Writer.Flush() + return nil +} + +// 获取 stub_status 模块数据 +func getStubStatusInfo() (map[string]int, error) { + result := map[string]int{ + "active": 0, "accepts": 0, "handled": 0, "requests": 0, + "reading": 0, "writing": 0, "waiting": 0, + } + + // 默认尝试访问 stub_status 页面 + statusURL := "http://localhost/stub_status" + + // 创建 HTTP 客户端 + client := &http.Client{ + Timeout: 5 * time.Second, + } + + // 发送请求获取 stub_status 数据 + resp, err := client.Get(statusURL) + if err != nil { + return result, fmt.Errorf("failed to get stub status: %v", err) + } + defer resp.Body.Close() + + // 读取响应内容 + body, err := io.ReadAll(resp.Body) + if err != nil { + return result, fmt.Errorf("failed to read response body: %v", err) + } + + // 解析响应内容 + statusContent := string(body) + + // 匹配活动连接数 + activeRe := regexp.MustCompile(`Active connections:\s+(\d+)`) + if matches := activeRe.FindStringSubmatch(statusContent); len(matches) > 1 { + result["active"], _ = strconv.Atoi(matches[1]) + } + + // 匹配请求统计信息 + serverRe := regexp.MustCompile(`(\d+)\s+(\d+)\s+(\d+)`) + if matches := serverRe.FindStringSubmatch(statusContent); len(matches) > 3 { + result["accepts"], _ = strconv.Atoi(matches[1]) + result["handled"], _ = strconv.Atoi(matches[2]) + result["requests"], _ = strconv.Atoi(matches[3]) + } + + // 匹配读写等待数 + connRe := regexp.MustCompile(`Reading:\s+(\d+)\s+Writing:\s+(\d+)\s+Waiting:\s+(\d+)`) + if matches := connRe.FindStringSubmatch(statusContent); len(matches) > 3 { + result["reading"], _ = strconv.Atoi(matches[1]) + result["writing"], _ = strconv.Atoi(matches[2]) + result["waiting"], _ = strconv.Atoi(matches[3]) + } + + return result, nil +} + +// 获取 Nginx 进程信息 +func getNginxProcessInfo() (map[string]interface{}, error) { + result := map[string]interface{}{ + "workers": 0, + "master": 0, + "cache": 0, + "other": 0, + "cpu_usage": 0.0, + "memory_usage": 0.0, + } + + // 查找所有 Nginx 进程 + processes, err := process.Processes() + if err != nil { + return result, fmt.Errorf("failed to get processes: %v", err) + } + + totalMemory := 0.0 + workerCount := 0 + masterCount := 0 + cacheCount := 0 + otherCount := 0 + nginxProcesses := []*process.Process{} + + // 获取系统CPU核心数 + numCPU := runtime.NumCPU() + + // 获取Nginx主进程的PID + var masterPID int32 = -1 + for _, p := range processes { + name, err := p.Name() + if err != nil { + continue + } + + cmdline, err := p.Cmdline() + if err != nil { + continue + } + + // 检查是否是Nginx主进程 + if strings.Contains(strings.ToLower(name), "nginx") && + (strings.Contains(cmdline, "master process") || + !strings.Contains(cmdline, "worker process")) && + p.Pid > 0 { + masterPID = p.Pid + masterCount++ + nginxProcesses = append(nginxProcesses, p) + + // 获取内存使用情况 - 使用RSS代替 + // 注意:理想情况下我们应该使用USS(仅包含进程独占内存),但gopsutil不直接支持 + mem, err := p.MemoryInfo() + if err == nil && mem != nil { + // 转换为 MB + memoryUsage := float64(mem.RSS) / 1024 / 1024 + totalMemory += memoryUsage + logger.Debug("Master进程内存使用(MB):", memoryUsage) + } + + break + } + } + + // 遍历所有进程,区分工作进程和其他Nginx进程 + for _, p := range processes { + if p.Pid == masterPID { + continue // 已经计算过主进程 + } + + name, err := p.Name() + if err != nil { + continue + } + + // 只处理Nginx相关进程 + if !strings.Contains(strings.ToLower(name), "nginx") { + continue + } + + // 添加到Nginx进程列表 + nginxProcesses = append(nginxProcesses, p) + + // 获取父进程PID + ppid, err := p.Ppid() + if err != nil { + continue + } + + cmdline, err := p.Cmdline() + if err != nil { + continue + } + + // 获取内存使用情况 - 使用RSS代替 + // 注意:理想情况下我们应该使用USS(仅包含进程独占内存),但gopsutil不直接支持 + mem, err := p.MemoryInfo() + if err == nil && mem != nil { + // 转换为 MB + memoryUsage := float64(mem.RSS) / 1024 / 1024 + totalMemory += memoryUsage + } + + // 区分工作进程、缓存进程和其他进程 + if ppid == masterPID || strings.Contains(cmdline, "worker process") { + workerCount++ + } else if strings.Contains(cmdline, "cache") { + cacheCount++ + } else { + otherCount++ + } + } + + // 重新计算CPU使用率,更接近top命令的计算方式 + // 首先进行初始CPU时间测量 + times1 := make(map[int32]float64) + for _, p := range nginxProcesses { + times, err := p.Times() + if err == nil { + // CPU时间 = 用户时间 + 系统时间 + times1[p.Pid] = times.User + times.System + } + } + + // 等待一小段时间 + time.Sleep(100 * time.Millisecond) + + // 再次测量CPU时间 + totalCPUPercent := 0.0 + for _, p := range nginxProcesses { + times, err := p.Times() + if err != nil { + continue + } + + // 计算CPU时间差 + currentTotal := times.User + times.System + if previousTotal, ok := times1[p.Pid]; ok { + // 计算这段时间内的CPU使用百分比(考虑多核) + cpuDelta := currentTotal - previousTotal + // 计算每秒CPU使用率(考虑采样时间) + cpuPercent := (cpuDelta / 0.1) * 100.0 / float64(numCPU) + totalCPUPercent += cpuPercent + } + } + + // 四舍五入到整数,更符合top显示方式 + totalCPUPercent = math.Round(totalCPUPercent) + + // 四舍五入内存使用量到两位小数 + totalMemory = math.Round(totalMemory*100) / 100 + + result["workers"] = workerCount + result["master"] = masterCount + result["cache"] = cacheCount + result["other"] = otherCount + result["cpu_usage"] = totalCPUPercent + result["memory_usage"] = totalMemory + + return result, nil +} + +// 获取 Nginx 配置信息 +func getNginxConfigInfo() (map[string]int, error) { + result := map[string]int{ + "worker_processes": 1, + "worker_connections": 1024, + } + + // 获取 worker_processes 配置 + cmd := exec.Command("nginx", "-T") + output, err := cmd.CombinedOutput() + if err != nil { + return result, fmt.Errorf("failed to get nginx config: %v", err) + } + + // 解析 worker_processes + wpRe := regexp.MustCompile(`worker_processes\s+(\d+|auto);`) + if matches := wpRe.FindStringSubmatch(string(output)); len(matches) > 1 { + if matches[1] == "auto" { + result["worker_processes"] = runtime.NumCPU() + } else { + result["worker_processes"], _ = strconv.Atoi(matches[1]) + } + } + + // 解析 worker_connections + wcRe := regexp.MustCompile(`worker_connections\s+(\d+);`) + if matches := wcRe.FindStringSubmatch(string(output)); len(matches) > 1 { + result["worker_connections"], _ = strconv.Atoi(matches[1]) + } + + return result, nil +} diff --git a/app/src/api/ngx.ts b/app/src/api/ngx.ts index cd043bf7..183a2390 100644 --- a/app/src/api/ngx.ts +++ b/app/src/api/ngx.ts @@ -35,6 +35,24 @@ export interface NgxLocation { export type DirectiveMap = Record +export interface NginxPerformanceInfo { + active: number // 活动连接数 + accepts: number // 总握手次数 + handled: number // 总连接次数 + requests: number // 总请求数 + reading: number // 读取客户端请求数 + writing: number // 响应数 + waiting: number // 驻留进程(等待请求) + workers: number // 工作进程数 + master: number // 主进程数 + cache: number // 缓存管理进程数 + other: number // 其他Nginx相关进程数 + cpu_usage: number // CPU 使用率 + memory_usage: number // 内存使用率(MB) + worker_processes: number // worker_processes 配置 + worker_connections: number // worker_connections 配置 +} + const ngx = { build_config(ngxConfig: NgxConfig) { return http.post('/ngx/build_config', ngxConfig) @@ -52,6 +70,17 @@ const ngx = { return http.get('/nginx/status') }, + detailed_status(): Promise<{ running: boolean, info: NginxPerformanceInfo }> { + return http.get('/nginx/detailed_status') + }, + + // 创建SSE连接获取实时Nginx性能数据 + create_detailed_status_stream(): EventSource { + const baseUrl = import.meta.env.VITE_API_URL || '' + const url = `${baseUrl}/api/nginx/detailed_status/stream` + return new EventSource(url) + }, + reload() { return http.post('/nginx/reload') }, diff --git a/app/src/components/EnvGroupTabs/EnvGroupTabs.vue b/app/src/components/EnvGroupTabs/EnvGroupTabs.vue index 44fdb1e9..d726d60b 100644 --- a/app/src/components/EnvGroupTabs/EnvGroupTabs.vue +++ b/app/src/components/EnvGroupTabs/EnvGroupTabs.vue @@ -2,9 +2,9 @@ import type { EnvGroup } from '@/api/env_group' import type { Environment } from '@/api/environment' import nodeApi from '@/api/node' +import { useSSE } from '@/composables/useSSE' import { useUserStore } from '@/pinia' import { message } from 'ant-design-vue' -import { SSE } from 'sse.js' const props = defineProps<{ envGroups: EnvGroup[] @@ -15,57 +15,39 @@ const { token } = storeToRefs(useUserStore()) const environments = ref([]) const environmentsMap = ref>({}) -const sse = shallowRef() const loading = ref({ reload: false, restart: false, }) +// 使用SSE composable +const { connect, disconnect } = useSSE() + // Get node data when tab is not 'All' watch(modelValue, newVal => { if (newVal && newVal !== 0) { connectSSE() } else { - disconnectSSE() + disconnect() } }, { immediate: true }) -onUnmounted(() => { - disconnectSSE() -}) - function connectSSE() { - disconnectSSE() - - const s = new SSE('api/environments/enabled', { - headers: { - Authorization: token.value, + connect({ + url: 'api/environments/enabled', + token: token.value, + onMessage: data => { + environments.value = data + environmentsMap.value = environments.value.reduce((acc, node) => { + acc[node.id] = node + return acc + }, {} as Record) + }, + onError: () => { + // 错误处理已由useSSE内部实现自动重连 }, }) - - s.onmessage = e => { - environments.value = JSON.parse(e.data) - environmentsMap.value = environments.value.reduce((acc, node) => { - acc[node.id] = node - return acc - }, {} as Record) - } - - s.onerror = () => { - setTimeout(() => { - connectSSE() - }, 5000) - } - - sse.value = s -} - -function disconnectSSE() { - if (sse.value) { - sse.value.close() - sse.value = undefined - } } // Get the current Node Group data diff --git a/app/src/composables/useNginxPerformance.ts b/app/src/composables/useNginxPerformance.ts new file mode 100644 index 00000000..b5b12581 --- /dev/null +++ b/app/src/composables/useNginxPerformance.ts @@ -0,0 +1,53 @@ +import type { NginxPerformanceInfo } from '@/api/ngx' +import ngx from '@/api/ngx' +import { computed, ref } from 'vue' + +export function useNginxPerformance() { + const loading = ref(true) + const nginxInfo = ref() + const error = ref('') + const lastUpdateTime = ref(new Date()) + + // 更新刷新时间 + function updateLastUpdateTime() { + lastUpdateTime.value = new Date() + } + + // 格式化上次更新时间 + const formattedUpdateTime = computed(() => { + return lastUpdateTime.value.toLocaleTimeString() + }) + + // 获取Nginx状态数据 + async function fetchInitialData() { + loading.value = true + error.value = '' + + try { + const result = await ngx.detailed_status() + nginxInfo.value = result.info + updateLastUpdateTime() + } + catch (e) { + if (e instanceof Error) { + error.value = e.message + } + else { + error.value = $gettext('Get data failed') + } + } + finally { + loading.value = false + } + } + + return { + loading, + nginxInfo, + error, + lastUpdateTime, + formattedUpdateTime, + updateLastUpdateTime, + fetchInitialData, + } +} diff --git a/app/src/composables/usePerformanceMetrics.ts b/app/src/composables/usePerformanceMetrics.ts new file mode 100644 index 00000000..0a05ca97 --- /dev/null +++ b/app/src/composables/usePerformanceMetrics.ts @@ -0,0 +1,194 @@ +import type { NginxPerformanceInfo } from '@/api/ngx' +import type { Ref } from 'vue' +import { computed } from 'vue' + +export function usePerformanceMetrics(nginxInfo: Ref) { + // 格式化数值为可读性更好的形式 + function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(2)}M` + } + else if (num >= 1000) { + return `${(num / 1000).toFixed(2)}K` + } + return num.toString() + } + + // 活跃连接百分比 + const activeConnectionsPercent = computed(() => { + if (!nginxInfo.value) { + return 0 + } + const maxConnections = nginxInfo.value.worker_connections * nginxInfo.value.worker_processes + return Number(((nginxInfo.value.active / maxConnections) * 100).toFixed(2)) + }) + + // 工作进程使用百分比 + const workerProcessesPercent = computed(() => { + if (!nginxInfo.value) { + return 0 + } + return Number(((nginxInfo.value.workers / nginxInfo.value.worker_processes) * 100).toFixed(2)) + }) + + // 每连接请求数 + const requestsPerConnection = computed(() => { + if (!nginxInfo.value || nginxInfo.value.handled === 0) { + return 0 + } + return (nginxInfo.value.requests / nginxInfo.value.handled).toFixed(2) + }) + + // 最大每秒请求数 + const maxRPS = computed(() => { + if (!nginxInfo.value) { + return 0 + } + return nginxInfo.value.worker_processes * nginxInfo.value.worker_connections + }) + + // 进程构成数据 + const processTypeData = computed(() => { + if (!nginxInfo.value) { + return [] + } + + return [ + { type: $gettext('Worker Processes'), value: nginxInfo.value.workers, color: '#1890ff' }, + { type: $gettext('Master Process'), value: nginxInfo.value.master, color: '#52c41a' }, + { type: $gettext('Cache Processes'), value: nginxInfo.value.cache, color: '#faad14' }, + { type: $gettext('Other Processes'), value: nginxInfo.value.other, color: '#f5222d' }, + ] + }) + + // 资源利用率 + const resourceUtilization = computed(() => { + if (!nginxInfo.value) { + return 0 + } + + const cpuFactor = Math.min(nginxInfo.value.cpu_usage / 100, 1) + const maxConnections = nginxInfo.value.worker_connections * nginxInfo.value.worker_processes + const connectionFactor = Math.min(nginxInfo.value.active / maxConnections, 1) + + return Math.round((cpuFactor * 0.5 + connectionFactor * 0.5) * 100) + }) + + // 表格数据 + const statusData = computed(() => { + if (!nginxInfo.value) { + return [] + } + + return [ + { + key: '1', + name: $gettext('Active connections'), + value: formatNumber(nginxInfo.value.active), + }, + { + key: '2', + name: $gettext('Total handshakes'), + value: formatNumber(nginxInfo.value.accepts), + }, + { + key: '3', + name: $gettext('Total connections'), + value: formatNumber(nginxInfo.value.handled), + }, + { + key: '4', + name: $gettext('Total requests'), + value: formatNumber(nginxInfo.value.requests), + }, + { + key: '5', + name: $gettext('Read requests'), + value: nginxInfo.value.reading, + }, + { + key: '6', + name: $gettext('Responses'), + value: nginxInfo.value.writing, + }, + { + key: '7', + name: $gettext('Waiting processes'), + value: nginxInfo.value.waiting, + }, + ] + }) + + // 工作进程数据 + const workerData = computed(() => { + if (!nginxInfo.value) { + return [] + } + + return [ + { + key: '1', + name: $gettext('Number of worker processes'), + value: nginxInfo.value.workers, + }, + { + key: '2', + name: $gettext('Master process'), + value: nginxInfo.value.master, + }, + { + key: '3', + name: $gettext('Cache manager processes'), + value: nginxInfo.value.cache, + }, + { + key: '4', + name: $gettext('Other Nginx processes'), + value: nginxInfo.value.other, + }, + { + key: '5', + name: $gettext('Nginx CPU usage rate'), + value: `${nginxInfo.value.cpu_usage.toFixed(2)}%`, + }, + { + key: '6', + name: $gettext('Nginx Memory usage'), + value: `${nginxInfo.value.memory_usage.toFixed(2)} MB`, + }, + ] + }) + + // 配置数据 + const configData = computed(() => { + if (!nginxInfo.value) { + return [] + } + + return [ + { + key: '1', + name: $gettext('Number of worker processes'), + value: nginxInfo.value.worker_processes, + }, + { + key: '2', + name: $gettext('Maximum number of connections per worker process'), + value: nginxInfo.value.worker_connections, + }, + ] + }) + + return { + formatNumber, + activeConnectionsPercent, + workerProcessesPercent, + requestsPerConnection, + maxRPS, + processTypeData, + resourceUtilization, + statusData, + workerData, + configData, + } +} diff --git a/app/src/composables/useSSE.ts b/app/src/composables/useSSE.ts new file mode 100644 index 00000000..befc567f --- /dev/null +++ b/app/src/composables/useSSE.ts @@ -0,0 +1,91 @@ +import type { SSEvent } from 'sse.js' +import { SSE } from 'sse.js' +import { onUnmounted, shallowRef } from 'vue' + +export interface SSEOptions { + url: string + token: string + onMessage?: (data: any) => void + onError?: () => void + parseData?: boolean + reconnectInterval?: number +} + +/** + * SSE 连接 Composable + * 提供创建、管理和自动清理 SSE 连接的能力 + */ +export function useSSE() { + const sseInstance = shallowRef() + + /** + * 连接 SSE 服务 + */ + function connect(options: SSEOptions) { + disconnect() + + const { + url, + token, + onMessage, + onError, + parseData = true, + reconnectInterval = 5000, + } = options + + const sse = new SSE(url, { + headers: { + Authorization: token, + }, + }) + + // 处理消息 + sse.onmessage = (e: SSEvent) => { + if (!e.data) { + return + } + + try { + const parsedData = parseData ? JSON.parse(e.data) : e.data + onMessage?.(parsedData) + } + catch (error) { + console.error('Error parsing SSE message:', error) + } + } + + // 处理错误并重连 + sse.onerror = () => { + onError?.() + + // 重连逻辑 + setTimeout(() => { + connect(options) + }, reconnectInterval) + } + + sseInstance.value = sse + return sse + } + + /** + * 断开 SSE 连接 + */ + function disconnect() { + if (sseInstance.value) { + sseInstance.value.close() + sseInstance.value = undefined + } + } + + // 组件卸载时自动断开连接 + onUnmounted(() => { + disconnect() + }) + + return { + connect, + disconnect, + sseInstance, + } +} diff --git a/app/src/lib/http/interceptors.ts b/app/src/lib/http/interceptors.ts index c251cc9f..13152df2 100644 --- a/app/src/lib/http/interceptors.ts +++ b/app/src/lib/http/interceptors.ts @@ -121,7 +121,7 @@ export function setupResponseInterceptor() { const otpModal = use2FAModal() // Handle authentication errors - if (error.response) { + if (error?.response) { switch (error.response.status) { case 401: secureSessionId.value = '' @@ -135,7 +135,7 @@ export function setupResponseInterceptor() { } // Handle JSON error that comes back as Blob for blob request type - if (error.response?.data instanceof Blob && error.response.data.type === 'application/json') { + if (error?.response?.data instanceof Blob && error?.response?.data?.type === 'application/json') { try { const text = await error.response.data.text() error.response.data = JSON.parse(text) diff --git a/app/src/routes/modules/dashboard.ts b/app/src/routes/modules/dashboard.ts index dea2d42d..c95b8b07 100644 --- a/app/src/routes/modules/dashboard.ts +++ b/app/src/routes/modules/dashboard.ts @@ -4,11 +4,29 @@ import { HomeOutlined } from '@ant-design/icons-vue' export const dashboardRoutes: RouteRecordRaw[] = [ { path: 'dashboard', - component: () => import('@/views/dashboard/DashBoard.vue'), + redirect: '/dashboard/server', name: 'Dashboard', meta: { name: () => $gettext('Dashboard'), icon: HomeOutlined, }, + children: [ + { + path: 'server', + component: () => import('@/views/dashboard/ServerDashBoard.vue'), + name: 'Server', + meta: { + name: () => $gettext('Server'), + }, + }, + { + path: 'nginx', + component: () => import('@/views/dashboard/NginxDashBoard.vue'), + name: 'NginxPerformance', + meta: { + name: () => $gettext('Nginx'), + }, + }, + ], }, ] diff --git a/app/src/version.json b/app/src/version.json index 0091b251..dabeb50b 100644 --- a/app/src/version.json +++ b/app/src/version.json @@ -1 +1 @@ -{"version":"2.0.0-rc.5","build_id":12,"total_build":406} \ No newline at end of file +{"version":"2.0.0-rc.5","build_id":13,"total_build":407} \ No newline at end of file diff --git a/app/src/views/dashboard/NginxDashBoard.vue b/app/src/views/dashboard/NginxDashBoard.vue new file mode 100644 index 00000000..55646cc5 --- /dev/null +++ b/app/src/views/dashboard/NginxDashBoard.vue @@ -0,0 +1,153 @@ + + + diff --git a/app/src/views/dashboard/DashBoard.vue b/app/src/views/dashboard/ServerDashBoard.vue similarity index 100% rename from app/src/views/dashboard/DashBoard.vue rename to app/src/views/dashboard/ServerDashBoard.vue diff --git a/app/src/views/dashboard/components/ConnectionMetricsCard.vue b/app/src/views/dashboard/components/ConnectionMetricsCard.vue new file mode 100644 index 00000000..49be3664 --- /dev/null +++ b/app/src/views/dashboard/components/ConnectionMetricsCard.vue @@ -0,0 +1,139 @@ + + + diff --git a/app/src/views/dashboard/components/PerformanceStatisticsCard.vue b/app/src/views/dashboard/components/PerformanceStatisticsCard.vue new file mode 100644 index 00000000..735d559f --- /dev/null +++ b/app/src/views/dashboard/components/PerformanceStatisticsCard.vue @@ -0,0 +1,102 @@ + + + diff --git a/app/src/views/dashboard/components/PerformanceTablesCard.vue b/app/src/views/dashboard/components/PerformanceTablesCard.vue new file mode 100644 index 00000000..44f3606c --- /dev/null +++ b/app/src/views/dashboard/components/PerformanceTablesCard.vue @@ -0,0 +1,211 @@ + + + diff --git a/app/src/views/dashboard/components/ProcessDistributionCard.vue b/app/src/views/dashboard/components/ProcessDistributionCard.vue new file mode 100644 index 00000000..b225c3d4 --- /dev/null +++ b/app/src/views/dashboard/components/ProcessDistributionCard.vue @@ -0,0 +1,53 @@ + + + diff --git a/app/src/views/dashboard/components/ResourceUsageCard.vue b/app/src/views/dashboard/components/ResourceUsageCard.vue new file mode 100644 index 00000000..63f222f7 --- /dev/null +++ b/app/src/views/dashboard/components/ResourceUsageCard.vue @@ -0,0 +1,87 @@ + + +