diff --git a/api/nginx/control.go b/api/nginx/control.go index f6b492a7..c2038f5e 100644 --- a/api/nginx/control.go +++ b/api/nginx/control.go @@ -1,10 +1,10 @@ package nginx import ( + "net/http" + "github.com/0xJacky/Nginx-UI/internal/nginx" "github.com/gin-gonic/gin" - "net/http" - "os" ) func Reload(c *gin.Context) { @@ -31,13 +31,9 @@ func Restart(c *gin.Context) { } func Status(c *gin.Context) { - pidPath := nginx.GetPIDPath() lastOutput := nginx.GetLastOutput() - running := true - if fileInfo, err := os.Stat(pidPath); err != nil || fileInfo.Size() == 0 { // fileInfo.Size() == 0 no process id - running = false - } + running := nginx.IsNginxRunning() c.JSON(http.StatusOK, gin.H{ "running": running, diff --git a/api/nginx/router.go b/api/nginx/router.go index d7c2e798..e7be797d 100644 --- a/api/nginx/router.go +++ b/api/nginx/router.go @@ -13,6 +13,14 @@ 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/detail_status", GetDetailStatus) + // 使用SSE推送Nginx详细状态信息 + r.GET("nginx/detail_status/stream", StreamDetailStatus) + // 获取 stub_status 模块状态 + r.GET("nginx/stub_status", CheckStubStatus) + // 启用或禁用 stub_status 模块 + r.POST("nginx/stub_status", ToggleStubStatus) 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..5b8d5e1b --- /dev/null +++ b/api/nginx/status.go @@ -0,0 +1,132 @@ +// GetDetailedStatus API 实现 +// 该功能用于解决 Issue #850,提供类似宝塔面板的 Nginx 负载监控功能 +// 返回详细的 Nginx 状态信息,包括请求统计、连接数、工作进程等数据 +package nginx + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/0xJacky/Nginx-UI/internal/nginx" + "github.com/gin-gonic/gin" + "github.com/uozi-tech/cosy" + "github.com/uozi-tech/cosy/logger" +) + +// NginxPerformanceInfo 存储 Nginx 性能相关信息 +type NginxPerformanceInfo struct { + // 基本状态信息 + nginx.StubStatusData + + // 进程相关信息 + nginx.NginxProcessInfo + + // 配置信息 + nginx.NginxConfigInfo +} + +// GetDetailStatus 获取 Nginx 详细状态信息 +func GetDetailStatus(c *gin.Context) { + response := nginx.GetPerformanceData() + c.JSON(http.StatusOK, response) +} + +// StreamDetailStatus 使用 SSE 流式推送 Nginx 详细状态信息 +func StreamDetailStatus(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 { + response := nginx.GetPerformanceData() + + // 发送 SSE 事件 + c.SSEvent("message", response) + + // 刷新缓冲区,确保数据立即发送 + c.Writer.Flush() + return nil +} + +// CheckStubStatus 获取 Nginx stub_status 模块状态 +func CheckStubStatus(c *gin.Context) { + stubStatus := nginx.GetStubStatus() + + c.JSON(http.StatusOK, stubStatus) +} + +// ToggleStubStatus 启用或禁用 stub_status 模块 +func ToggleStubStatus(c *gin.Context) { + var json struct { + Enable bool `json:"enable"` + } + + if !cosy.BindAndValid(c, &json) { + return + } + + stubStatus := nginx.GetStubStatus() + + // 如果当前状态与期望状态相同,则无需操作 + if stubStatus.Enabled == json.Enable { + c.JSON(http.StatusOK, stubStatus) + return + } + + var err error + if json.Enable { + err = nginx.EnableStubStatus() + } else { + err = nginx.DisableStubStatus() + } + + if err != nil { + cosy.ErrHandler(c, err) + return + } + + // 重新加载 Nginx 配置 + reloadOutput := nginx.Reload() + if len(reloadOutput) > 0 && (strings.Contains(strings.ToLower(reloadOutput), "error") || + strings.Contains(strings.ToLower(reloadOutput), "failed")) { + cosy.ErrHandler(c, errors.New("Reload Nginx failed")) + return + } + + // 检查操作后的状态 + newStubStatus := nginx.GetStubStatus() + + c.JSON(http.StatusOK, newStubStatus) +} diff --git a/app/src/api/ngx.ts b/app/src/api/ngx.ts index cd043bf7..76fcc869 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,10 @@ const ngx = { return http.get('/nginx/status') }, + detail_status(): Promise<{ running: boolean, stub_status_enabled: boolean, info: NginxPerformanceInfo }> { + return http.get('/nginx/detail_status') + }, + 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..dea0490d --- /dev/null +++ b/app/src/composables/useNginxPerformance.ts @@ -0,0 +1,88 @@ +import type { NginxPerformanceInfo } from '@/api/ngx' +import ngx from '@/api/ngx' +import { computed, ref } from 'vue' + +// Time formatting helper function +function formatTimeAgo(date: Date): string { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSec = Math.round(diffMs / 1000) + + if (diffSec < 60) { + return `${diffSec} ${$gettext('秒前')}` + } + + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) { + return `${diffMin} ${$gettext('分钟前')}` + } + + return date.toLocaleTimeString() +} + +export function useNginxPerformance() { + const loading = ref(false) + const error = ref('') + const nginxInfo = ref(null) + const lastUpdateTime = ref(null) + + // stub_status availability + const stubStatusEnabled = ref(false) + const stubStatusLoading = ref(false) + const stubStatusError = ref('') + + // Format the last update time + const formattedUpdateTime = computed(() => { + if (!lastUpdateTime.value) + return $gettext('Unknown') + return formatTimeAgo(lastUpdateTime.value) + }) + + // Update the last update time + function updateLastUpdateTime() { + lastUpdateTime.value = new Date() + } + + // Check stub_status availability and get initial data + async function fetchInitialData() { + try { + loading.value = true + stubStatusLoading.value = true + error.value = '' + + // Get performance data + const response = await ngx.detail_status() + + if (response.running) { + stubStatusEnabled.value = response.stub_status_enabled + nginxInfo.value = response.info + updateLastUpdateTime() + } + else { + error.value = $gettext('Nginx is not running') + nginxInfo.value = null + } + } + catch (err) { + console.error('Failed to get Nginx performance data:', err) + error.value = $gettext('Failed to get performance data') + nginxInfo.value = null + } + finally { + loading.value = false + stubStatusLoading.value = false + } + } + + return { + loading, + nginxInfo, + error, + formattedUpdateTime, + updateLastUpdateTime, + fetchInitialData, + stubStatusEnabled, + stubStatusLoading, + stubStatusError, + } +} diff --git a/app/src/composables/usePerformanceMetrics.ts b/app/src/composables/usePerformanceMetrics.ts new file mode 100644 index 00000000..c10ae275 --- /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) { + // Format numbers to a more readable form + 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() + } + + // Active connections percentage + 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)) + }) + + // Worker processes usage percentage + const workerProcessesPercent = computed(() => { + if (!nginxInfo.value) { + return 0 + } + return Number(((nginxInfo.value.workers / nginxInfo.value.worker_processes) * 100).toFixed(2)) + }) + + // Requests per connection + const requestsPerConnection = computed(() => { + if (!nginxInfo.value || nginxInfo.value.handled === 0) { + return 0 + } + return (nginxInfo.value.requests / nginxInfo.value.handled).toFixed(2) + }) + + // Maximum requests per second + const maxRPS = computed(() => { + if (!nginxInfo.value) { + return 0 + } + return nginxInfo.value.worker_processes * nginxInfo.value.worker_connections + }) + + // Process composition data + 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' }, + ] + }) + + // Resource utilization + 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) + }) + + // Table data + 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, + }, + ] + }) + + // Worker processes data + 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`, + }, + ] + }) + + // Configuration data + 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..9ad9a0dd --- /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 + * Provide the ability to create, manage, and automatically clean up SSE connections + */ +export function useSSE() { + const sseInstance = shallowRef() + + /** + * Connect to SSE service + */ + function connect(options: SSEOptions) { + disconnect() + + const { + url, + token, + onMessage, + onError, + parseData = true, + reconnectInterval = 5000, + } = options + + const sse = new SSE(url, { + headers: { + Authorization: token, + }, + }) + + // Handle messages + 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) + } + } + + // Handle errors and reconnect + sse.onerror = () => { + onError?.() + + // Reconnect logic + setTimeout(() => { + connect(options) + }, reconnectInterval) + } + + sseInstance.value = sse + return sse + } + + /** + * Disconnect SSE connection + */ + function disconnect() { + if (sseInstance.value) { + sseInstance.value.close() + sseInstance.value = undefined + } + } + + // Automatically disconnect when the component is unmounted + onUnmounted(() => { + disconnect() + }) + + return { + connect, + disconnect, + sseInstance, + } +} diff --git a/app/src/lib/http/interceptors.ts b/app/src/lib/http/interceptors.ts index c251cc9f..1a2dc911 100644 --- a/app/src/lib/http/interceptors.ts +++ b/app/src/lib/http/interceptors.ts @@ -109,8 +109,9 @@ export function setupResponseInterceptor() { instance.interceptors.response.use( response => { nprogress.done() + // Check if full response is requested in config - if (response.config?.returnFullResponse) { + if (response?.config?.returnFullResponse) { return Promise.resolve(response) } return Promise.resolve(response.data) @@ -121,7 +122,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 +136,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..3999d36e --- /dev/null +++ b/app/src/views/dashboard/NginxDashBoard.vue @@ -0,0 +1,220 @@ + + + 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..fd1bb0f5 --- /dev/null +++ b/app/src/views/dashboard/components/ConnectionMetricsCard.vue @@ -0,0 +1,64 @@ + + + diff --git a/app/src/views/dashboard/components/PerformanceStatisticsCard.vue b/app/src/views/dashboard/components/PerformanceStatisticsCard.vue new file mode 100644 index 00000000..406a9b72 --- /dev/null +++ b/app/src/views/dashboard/components/PerformanceStatisticsCard.vue @@ -0,0 +1,107 @@ + + + diff --git a/app/src/views/dashboard/components/PerformanceTablesCard.vue b/app/src/views/dashboard/components/PerformanceTablesCard.vue new file mode 100644 index 00000000..7c94e8f4 --- /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..f30821db --- /dev/null +++ b/app/src/views/dashboard/components/ProcessDistributionCard.vue @@ -0,0 +1,60 @@ + + + diff --git a/app/src/views/dashboard/components/ResourceUsageCard.vue b/app/src/views/dashboard/components/ResourceUsageCard.vue new file mode 100644 index 00000000..f2128d13 --- /dev/null +++ b/app/src/views/dashboard/components/ResourceUsageCard.vue @@ -0,0 +1,61 @@ + + + diff --git a/internal/nginx/config_info.go b/internal/nginx/config_info.go new file mode 100644 index 00000000..c331582d --- /dev/null +++ b/internal/nginx/config_info.go @@ -0,0 +1,48 @@ +package nginx + +import ( + "os/exec" + "regexp" + "runtime" + "strconv" + + "github.com/pkg/errors" +) + +type NginxConfigInfo struct { + WorkerProcesses int `json:"worker_processes"` + WorkerConnections int `json:"worker_connections"` +} + +// GetNginxWorkerConfigInfo Get Nginx config info of worker_processes and worker_connections +func GetNginxWorkerConfigInfo() (*NginxConfigInfo, error) { + result := &NginxConfigInfo{ + WorkerProcesses: 1, + WorkerConnections: 1024, + } + + // Get worker_processes config + cmd := exec.Command("nginx", "-T") + output, err := cmd.CombinedOutput() + if err != nil { + return result, errors.Wrap(err, "failed to get nginx config") + } + + // Parse worker_processes + wpRe := regexp.MustCompile(`worker_processes\s+(\d+|auto);`) + if matches := wpRe.FindStringSubmatch(string(output)); len(matches) > 1 { + if matches[1] == "auto" { + result.WorkerProcesses = runtime.NumCPU() + } else { + result.WorkerProcesses, _ = strconv.Atoi(matches[1]) + } + } + + // Parse worker_connections + wcRe := regexp.MustCompile(`worker_connections\s+(\d+);`) + if matches := wcRe.FindStringSubmatch(string(output)); len(matches) > 1 { + result.WorkerConnections, _ = strconv.Atoi(matches[1]) + } + + return result, nil +} diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go index e27d0abf..ea7e4ce3 100644 --- a/internal/nginx/nginx.go +++ b/internal/nginx/nginx.go @@ -1,6 +1,7 @@ package nginx import ( + "os" "os/exec" "strings" "sync" @@ -115,3 +116,11 @@ func execCommand(name string, cmd ...string) (out string) { } return } + +func IsNginxRunning() bool { + pidPath := GetPIDPath() + if fileInfo, err := os.Stat(pidPath); err != nil || fileInfo.Size() == 0 { + return false + } + return true +} diff --git a/internal/nginx/performance.go b/internal/nginx/performance.go new file mode 100644 index 00000000..ebec965d --- /dev/null +++ b/internal/nginx/performance.go @@ -0,0 +1,55 @@ +package nginx + +import "github.com/uozi-tech/cosy/logger" + +type NginxPerformanceInfo struct { + StubStatusData + NginxProcessInfo + NginxConfigInfo +} + +type NginxPerformanceResponse struct { + StubStatusEnabled bool `json:"stub_status_enabled"` + Running bool `json:"running"` + Info NginxPerformanceInfo `json:"info"` +} + +func GetPerformanceData() NginxPerformanceResponse { + // Check if Nginx is running + running := IsNginxRunning() + if !running { + return NginxPerformanceResponse{ + StubStatusEnabled: false, + Running: false, + Info: NginxPerformanceInfo{}, + } + } + + // Get Nginx status information + stubStatusEnabled, statusInfo, err := GetStubStatusData() + if err != nil { + logger.Warn("Failed to get Nginx status:", err) + } + + // Get Nginx process information + processInfo, err := GetNginxProcessInfo() + if err != nil { + logger.Warn("Failed to get Nginx process info:", err) + } + + // Get Nginx config information + configInfo, err := GetNginxWorkerConfigInfo() + if err != nil { + logger.Warn("Failed to get Nginx config info:", err) + } + + return NginxPerformanceResponse{ + StubStatusEnabled: stubStatusEnabled, + Running: running, + Info: NginxPerformanceInfo{ + StubStatusData: *statusInfo, + NginxProcessInfo: *processInfo, + NginxConfigInfo: *configInfo, + }, + } +} diff --git a/internal/nginx/process_info.go b/internal/nginx/process_info.go new file mode 100644 index 00000000..04aff025 --- /dev/null +++ b/internal/nginx/process_info.go @@ -0,0 +1,178 @@ +package nginx + +import ( + "fmt" + "math" + "runtime" + "strings" + "time" + + "github.com/shirou/gopsutil/v4/process" +) + +type NginxProcessInfo struct { + Workers int `json:"workers"` + Master int `json:"master"` + Cache int `json:"cache"` + Other int `json:"other"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` +} + +// GetNginxProcessInfo Get Nginx process information +func GetNginxProcessInfo() (*NginxProcessInfo, error) { + result := &NginxProcessInfo{ + Workers: 0, + Master: 0, + Cache: 0, + Other: 0, + CPUUsage: 0.0, + MemoryUsage: 0.0, + } + + // Find all Nginx processes + 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{} + + // Get the number of system CPU cores + numCPU := runtime.NumCPU() + + // Get the PID of the Nginx master process + var masterPID int32 = -1 + for _, p := range processes { + name, err := p.Name() + if err != nil { + continue + } + + cmdline, err := p.Cmdline() + if err != nil { + continue + } + + // Check if it is the Nginx master process + 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) + + // Get the memory usage + mem, err := p.MemoryInfo() + if err == nil && mem != nil { + // Convert to MB + memoryUsage := float64(mem.RSS) / 1024 / 1024 + totalMemory += memoryUsage + } + + break + } + } + + // Iterate through all processes, distinguishing between worker processes and other Nginx processes + for _, p := range processes { + if p.Pid == masterPID { + continue // Already calculated the master process + } + + name, err := p.Name() + if err != nil { + continue + } + + // Only process Nginx related processes + if !strings.Contains(strings.ToLower(name), "nginx") { + continue + } + + // Add to the Nginx process list + nginxProcesses = append(nginxProcesses, p) + + // Get the parent process PID + ppid, err := p.Ppid() + if err != nil { + continue + } + + cmdline, err := p.Cmdline() + if err != nil { + continue + } + + // Get the memory usage + mem, err := p.MemoryInfo() + if err == nil && mem != nil { + // Convert to MB + memoryUsage := float64(mem.RSS) / 1024 / 1024 + totalMemory += memoryUsage + } + + // Distinguish between worker processes, cache processes, and other processes + if ppid == masterPID || strings.Contains(cmdline, "worker process") { + workerCount++ + } else if strings.Contains(cmdline, "cache") { + cacheCount++ + } else { + otherCount++ + } + } + + // Calculate the CPU usage + // First, measure the initial CPU time + times1 := make(map[int32]float64) + for _, p := range nginxProcesses { + times, err := p.Times() + if err == nil { + // CPU time = user time + system time + times1[p.Pid] = times.User + times.System + } + } + + // Wait for a short period of time + time.Sleep(100 * time.Millisecond) + + // Measure the CPU time again + totalCPUPercent := 0.0 + for _, p := range nginxProcesses { + times, err := p.Times() + if err != nil { + continue + } + + // Calculate the CPU time difference + currentTotal := times.User + times.System + if previousTotal, ok := times1[p.Pid]; ok { + // Calculate the CPU usage percentage during this period (considering multiple cores) + cpuDelta := currentTotal - previousTotal + // Calculate the CPU usage per second (considering the sampling time) + cpuPercent := (cpuDelta / 0.1) * 100.0 / float64(numCPU) + totalCPUPercent += cpuPercent + } + } + + // Round to the nearest integer, which is more consistent with the top display + totalCPUPercent = math.Round(totalCPUPercent) + + // Round the memory usage to two decimal places + totalMemory = math.Round(totalMemory*100) / 100 + + result.Workers = workerCount + result.Master = masterCount + result.Cache = cacheCount + result.Other = otherCount + result.CPUUsage = totalCPUPercent + result.MemoryUsage = totalMemory + + return result, nil +} diff --git a/internal/nginx/stub_status.go b/internal/nginx/stub_status.go new file mode 100644 index 00000000..a7787c42 --- /dev/null +++ b/internal/nginx/stub_status.go @@ -0,0 +1,199 @@ +package nginx + +import ( + "fmt" + "io" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" +) + +// StubStatusInfo Store the stub_status module status +type StubStatusInfo struct { + Enabled bool `json:"stub_status_enabled"` // stub_status module is enabled + URL string `json:"stub_status_url"` // stub_status access address +} + +type StubStatusData 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"` +} + +const ( + StubStatusPort = 51828 + StubStatusPath = "/stub_status" + StubStatusHost = "localhost" + StubStatusProtocol = "http" + StubStatusAllow = "127.0.0.1" + StubStatusDeny = "all" + StubStatusConfigName = "stub_status_nginx-ui.conf" +) + +// GetStubStatusData Get the stub_status module data +func GetStubStatusData() (bool, *StubStatusData, error) { + result := &StubStatusData{ + Active: 0, + Accepts: 0, + Handled: 0, + Requests: 0, + Reading: 0, + Writing: 0, + Waiting: 0, + } + + // Get the stub_status status information + enabled, statusURL := IsStubStatusEnabled() + if !enabled { + return false, result, fmt.Errorf("stub_status is not enabled") + } + + // Create an HTTP client + client := &http.Client{ + Timeout: 5 * time.Second, + } + + // Send a request to get the stub_status data + resp, err := client.Get(statusURL) + if err != nil { + return enabled, result, fmt.Errorf("failed to get stub status: %v", err) + } + defer resp.Body.Close() + + // Read the response content + body, err := io.ReadAll(resp.Body) + if err != nil { + return enabled, result, fmt.Errorf("failed to read response body: %v", err) + } + + // Parse the response content + statusContent := string(body) + + // Match the active connection number + activeRe := regexp.MustCompile(`Active connections:\s+(\d+)`) + if matches := activeRe.FindStringSubmatch(statusContent); len(matches) > 1 { + result.Active, _ = strconv.Atoi(matches[1]) + } + + // Match the request statistics information + 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]) + } + + // Match the read and write waiting numbers + 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 enabled, result, nil +} + +// GetStubStatus Get the stub_status module status +func GetStubStatus() *StubStatusInfo { + enabled, statusURL := IsStubStatusEnabled() + return &StubStatusInfo{ + Enabled: enabled, + URL: statusURL, + } +} + +// IsStubStatusEnabled Check if the stub_status module is enabled and return the access address +// Only check the stub_status_nginx-ui.conf configuration file +func IsStubStatusEnabled() (bool, string) { + stubStatusConfPath := GetConfPath("conf.d", StubStatusConfigName) + if _, err := os.Stat(stubStatusConfPath); os.IsNotExist(err) { + return false, "" + } + + ngxConfig, err := ParseNgxConfig(stubStatusConfPath) + if err != nil { + return false, "" + } + + // Find the stub_status configuration + for _, server := range ngxConfig.Servers { + protocol := StubStatusProtocol + host := StubStatusHost + port := strconv.Itoa(StubStatusPort) + + for _, location := range server.Locations { + // Check if the location content contains stub_status + if strings.Contains(location.Content, "stub_status") { + stubStatusURL := fmt.Sprintf("%s://%s:%s%s", protocol, host, port, StubStatusPath) + return true, stubStatusURL + } + } + } + + return false, "" +} + +// EnableStubStatus Enable stub_status module +func EnableStubStatus() error { + enabled, _ := IsStubStatusEnabled() + if enabled { + return nil + } + + return CreateStubStatusConfig() +} + +// DisableStubStatus Disable stub_status module +func DisableStubStatus() error { + stubStatusConfPath := GetConfPath("conf.d", StubStatusConfigName) + if _, err := os.Stat(stubStatusConfPath); os.IsNotExist(err) { + return nil + } + + return os.Remove(stubStatusConfPath) +} + +// CreateStubStatusConfig Create a new stub_status configuration file +func CreateStubStatusConfig() error { + httpConfPath := GetConfPath("conf.d", StubStatusConfigName) + + stubStatusConfig := ` +# DO NOT EDIT THIS FILE, IT IS AUTO GENERATED BY NGINX-UI +# Nginx stub_status configuration for Nginx-UI +# Modified at ` + time.Now().Format("2006-01-02 15:04:05") + ` + +server { + listen 51828; # Use non-standard port to avoid conflicts + server_name localhost; + + # Status monitoring interface + location /stub_status { + stub_status; + allow 127.0.0.1; # Only allow local access + deny all; + } +} +` + ngxConfig, err := ParseNgxConfigByContent(stubStatusConfig) + if err != nil { + return errors.Wrap(err, "failed to parse new nginx config") + } + + ngxConfig.FileName = httpConfPath + configText, err := ngxConfig.BuildConfig() + if err != nil { + return errors.Wrap(err, "failed to build nginx config") + } + + return os.WriteFile(httpConfPath, []byte(configText), 0644) +}