Browse Source

feat(wip): add node system monitor in dashboard

0xJacky 1 year ago
parent
commit
2635d0134c

+ 1 - 0
frontend/components.d.ts

@@ -67,6 +67,7 @@ declare module '@vue/runtime-core' {
     BreadcrumbBreadcrumb: typeof import('./src/components/Breadcrumb/Breadcrumb.vue')['default']
     ChartAreaChart: typeof import('./src/components/Chart/AreaChart.vue')['default']
     ChartRadialBarChart: typeof import('./src/components/Chart/RadialBarChart.vue')['default']
+    ChartUsageProgressLine: typeof import('./src/components/Chart/UsageProgressLine.vue')['default']
     ChatGPTChatGPT: typeof import('./src/components/ChatGPT/ChatGPT.vue')['default']
     CodeEditorCodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
     EnvIndicatorEnvIndicator: typeof import('./src/components/EnvIndicator/EnvIndicator.vue')['default']

+ 52 - 0
frontend/src/components/Chart/UsageProgressLine.vue

@@ -0,0 +1,52 @@
+<script setup lang="ts">
+import {computed} from 'vue'
+
+const props = withDefaults(defineProps<{
+    percent: number
+}>(), {
+    percent: 0
+})
+
+const color = computed(() => {
+    if (props.percent < 80) {
+        return '#1890ff'
+    } else if (props.percent >= 80 && props.percent < 90) {
+        return '#faad14'
+    } else {
+        return '#ff6385'
+    }
+})
+
+const fixed_percent = computed(() => {
+    return parseFloat(props.percent.toFixed(2))
+})
+</script>
+
+<template>
+    <div>
+        <div>
+            <span class="slot-icon"><slot name="icon"></slot></span>
+            <span class="slot">
+                <slot></slot>
+            </span>
+            <span class="dot"> ·</span> {{ fixed_percent + '%' }}
+        </div>
+        <a-progress :percent="fixed_percent" :stroke-color="color" :show-info="false"/>
+    </div>
+</template>
+
+<style scoped lang="less">
+.slot-icon {
+    margin-right: 5px;
+}
+
+@media (max-width: 1000px) {
+    .dot {
+        display: none;
+    }
+
+    .slot {
+        display: none;
+    }
+}
+</style>

+ 33 - 7
frontend/src/views/dashboard/Environments.vue

@@ -1,24 +1,53 @@
 <script setup lang="ts">
 import {useSettingsStore} from '@/pinia'
 import {useGettext} from 'vue3-gettext'
-import {computed, ref} from 'vue'
+import {computed, onMounted, onUnmounted, ref} from 'vue'
 import environment from '@/api/environment'
 import Icon, {LinkOutlined, SendOutlined, ThunderboltOutlined} from '@ant-design/icons-vue'
 import logo from '@/assets/img/logo.png'
 import pulse from '@/assets/svg/pulse.svg'
-import cpu from '@/assets/svg/cpu.svg'
-import memory from '@/assets/svg/memory.svg'
 import {formatDateTime} from '@/lib/helper'
+import ws from '@/lib/websocket'
+import ReconnectingWebSocket from 'reconnecting-websocket'
+import NodeAnalyticItem from '@/views/dashboard/components/NodeAnalyticItem.vue'
 
 const settingsStore = useSettingsStore()
 const {$gettext} = useGettext()
 
 const data = ref([])
 
+const node_map = computed(() => {
+    const o = {}
+    data.value.forEach(v => {
+        o[v.id] = v
+    })
+    return o
+})
+
 environment.get_list().then(r => {
     data.value = r.data
 })
 
+let websocket: ReconnectingWebSocket | WebSocket
+
+onMounted(() => {
+    websocket = ws('/api/analytic/nodes')
+    websocket.onmessage = m => {
+        const nodes = JSON.parse(m.data)
+        for (let key in nodes) {
+            // update node online status
+            if (node_map.value[key]) {
+                Object.assign(node_map.value[key], nodes[key])
+                node_map.value[key].response_at = new Date()
+            }
+        }
+    }
+})
+
+onUnmounted(() => {
+    websocket.close()
+})
+
 export interface Node {
     id: number
     name: string
@@ -69,10 +98,7 @@ const visible = computed(() => {
                             <a-avatar :src="logo"/>
                         </template>
                         <template #description>
-                            <div class="runtime-meta">
-                                <span><Icon :component="cpu"/> {{ item.cpu_num }} CPU</span>
-                                <span><Icon :component="memory"/> {{ item.memory_total }}</span>
-                            </div>
+                            <node-analytic-item :item="item"/>
                         </template>
                     </a-list-item-meta>
                 </a-list-item>

+ 83 - 0
frontend/src/views/dashboard/components/NodeAnalyticItem.vue

@@ -0,0 +1,83 @@
+<script setup lang="ts">
+import cpu from '@/assets/svg/cpu.svg'
+import memory from '@/assets/svg/memory.svg'
+import {bytesToSize} from '@/lib/helper'
+import Icon, {ArrowDownOutlined, ArrowUpOutlined, DatabaseOutlined, LineChartOutlined} from '@ant-design/icons-vue'
+import UsageProgressLine from '@/components/Chart/UsageProgressLine.vue'
+
+const props = defineProps(['item'])
+</script>
+
+<template>
+    <div class="hardware-monitor">
+        <a-row>
+            <a-col :xs="12" :md="10">
+                <div class="hardware-monitor-item longer">
+                    <div>
+                        <line-chart-outlined/>
+                        <span class="load-avg-describe">1min:</span>{{ ' ' + item.avg_load?.load1?.toFixed(2) }} ·
+                        <span class="load-avg-describe">5min:</span>{{ item.avg_load?.load5?.toFixed(2) }} ·
+                        <span class="load-avg-describe">15min:</span>{{ item.avg_load?.load15?.toFixed(2) }}
+                    </div>
+                    <div>
+                        <arrow-up-outlined/>
+                        {{ bytesToSize(item?.network?.bytesSent) }}
+                        <arrow-down-outlined/>
+                        {{ bytesToSize(item?.network?.bytesRecv) }}
+                    </div>
+                </div>
+            </a-col>
+            <a-col :xs="12" :md="14">
+                <div class="hardware-monitor-item">
+                    <usage-progress-line :percent="item.cpu_percent">
+                        <template #icon>
+                            <Icon :component="cpu"/>
+                        </template>
+                        <span>{{ item.cpu_num }} CPU</span>
+                    </usage-progress-line>
+                </div>
+                <div class="hardware-monitor-item">
+                    <usage-progress-line :percent="item.memory_percent">
+                        <template #icon>
+                            <Icon :component="memory"/>
+                        </template>
+                        <span>{{ item.memory_total }}</span>
+                    </usage-progress-line>
+                </div>
+                <div class="hardware-monitor-item">
+                    <usage-progress-line :percent="item.disk_percent">
+                        <template #icon>
+                            <database-outlined/>
+                        </template>
+                        <span>{{ item.disk_total }}</span>
+                    </usage-progress-line>
+                </div>
+            </a-col>
+        </a-row>
+    </div>
+</template>
+
+<style scoped lang="less">
+.hardware-monitor {
+    display: flex;
+
+    :deep(.ant-col) {
+        display: flex;
+    }
+
+    .hardware-monitor-item {
+        width: 150px;
+        margin-right: 30px;
+    }
+
+    .longer {
+        width: 300px;
+    }
+}
+
+.load-avg-describe {
+    @media (max-width: 1200px) {
+        display: none;
+    }
+}
+</style>

+ 102 - 1
server/api/analytic.go

@@ -157,7 +157,6 @@ func Analytic(c *gin.Context) {
 		}
 		time.Sleep(800 * time.Microsecond)
 	}
-
 }
 
 func GetAnalyticInit(c *gin.Context) {
@@ -213,3 +212,105 @@ func GetAnalyticInit(c *gin.Context) {
 		"loadavg": loadAvg,
 	})
 }
+
+func GetIntroAnalytic(c *gin.Context) {
+	var upGrader = websocket.Upgrader{
+		CheckOrigin: func(r *http.Request) bool {
+			return true
+		},
+	}
+	// upgrade http to websocket
+	ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
+	if err != nil {
+		logger.Error(err)
+		return
+	}
+
+	defer ws.Close()
+
+	for {
+		memory, err := getMemoryStat()
+
+		if err != nil {
+			logger.Error(err)
+			return
+		}
+
+		cpuTimesBefore, _ := cpu.Times(false)
+		time.Sleep(1000 * time.Millisecond)
+		cpuTimesAfter, _ := cpu.Times(false)
+		threadNum := runtime.GOMAXPROCS(0)
+		cpuUserUsage := (cpuTimesAfter[0].User - cpuTimesBefore[0].User) / (float64(1000*threadNum) / 1000)
+		cpuSystemUsage := (cpuTimesAfter[0].System - cpuTimesBefore[0].System) / (float64(1000*threadNum) / 1000)
+
+		loadAvg, err := load.Avg()
+
+		if err != nil {
+			logger.Error(err)
+			return
+		}
+
+		diskStat, err := getDiskStat()
+
+		if err != nil {
+			logger.Error(err)
+			return
+		}
+
+		netIO, err := net.IOCounters(false)
+
+		if err != nil {
+			logger.Error(err)
+			return
+		}
+
+		var network net.IOCountersStat
+		if len(netIO) > 0 {
+			network = netIO[0]
+		}
+
+		data := analytic.Node{
+			AvgLoad:       loadAvg,
+			CPUPercent:    math.Min((cpuUserUsage+cpuSystemUsage)*100, 100),
+			MemoryPercent: memory.Pressure,
+			DiskPercent:   diskStat.Percentage,
+			Network:       network,
+		}
+
+		// write
+		err = ws.WriteJSON(data)
+		if err != nil {
+			logger.Error(err)
+			break
+		}
+
+		time.Sleep(5 * time.Second)
+	}
+}
+
+func GetNodesAnalytic(c *gin.Context) {
+	var upGrader = websocket.Upgrader{
+		CheckOrigin: func(r *http.Request) bool {
+			return true
+		},
+	}
+	// upgrade http to websocket
+	ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
+	if err != nil {
+		logger.Error(err)
+		return
+	}
+
+	defer ws.Close()
+
+	for {
+		// write
+		err = ws.WriteJSON(analytic.NodeMap)
+		if err != nil {
+			logger.Error(err)
+			break
+		}
+
+		time.Sleep(5 * time.Second)
+	}
+}

+ 4 - 0
server/api/node.go

@@ -2,8 +2,10 @@ package api
 
 import (
 	"github.com/0xJacky/Nginx-UI/server/service"
+	"github.com/dustin/go-humanize"
 	"github.com/gin-gonic/gin"
 	"github.com/shirou/gopsutil/v3/cpu"
+	"github.com/shirou/gopsutil/v3/disk"
 	"net/http"
 )
 
@@ -24,12 +26,14 @@ func GetCurrentNode(c *gin.Context) {
 	cpuInfo, _ := cpu.Info()
 	memory, _ := getMemoryStat()
 	ver, _ := service.GetCurrentVersion()
+	diskUsage, _ := disk.Usage(".")
 
 	c.JSON(http.StatusOK, gin.H{
 		"request_node_secret": c.MustGet("NodeSecret"),
 		"node_runtime_info":   runtimeInfo,
 		"cpu_num":             len(cpuInfo),
 		"memory_total":        memory.Total,
+		"disk_total":          humanize.Bytes(diskUsage.Total),
 		"version":             ver.Version,
 	})
 }

+ 115 - 0
server/internal/analytic/node.go

@@ -0,0 +1,115 @@
+package analytic
+
+import (
+	"encoding/json"
+	"github.com/0xJacky/Nginx-UI/server/internal/logger"
+	"github.com/0xJacky/Nginx-UI/server/model"
+	"github.com/0xJacky/Nginx-UI/server/query"
+	"github.com/gorilla/websocket"
+	"github.com/opentracing/opentracing-go/log"
+	"github.com/shirou/gopsutil/v3/load"
+	"github.com/shirou/gopsutil/v3/net"
+	"net/http"
+	"time"
+)
+
+type Node struct {
+	EnvironmentID int                `json:"environment_id,omitempty"`
+	Name          string             `json:"name,omitempty"`
+	AvgLoad       *load.AvgStat      `json:"avg_load"`
+	CPUPercent    float64            `json:"cpu_percent"`
+	MemoryPercent float64            `json:"memory_percent"`
+	DiskPercent   float64            `json:"disk_percent"`
+	Network       net.IOCountersStat `json:"network"`
+	Status        bool               `json:"status"`
+}
+type TNodeMap map[int]*Node
+
+var NodeMap TNodeMap
+
+func init() {
+	NodeMap = make(TNodeMap)
+}
+
+func nodeAnalyticLive(env *model.Environment, errChan chan error) {
+	for {
+		err := nodeAnalyticRecord(env)
+
+		if err != nil {
+			// set node offline
+			if NodeMap[env.ID] != nil {
+				NodeMap[env.ID].Status = false
+			}
+			log.Error(err)
+			errChan <- err
+			// wait 5s then reconnect
+			time.Sleep(5 * time.Second)
+		}
+	}
+}
+
+func nodeAnalyticRecord(env *model.Environment) (err error) {
+	url, err := env.GetWebSocketURL("/api/analytic/intro")
+
+	if err != nil {
+		return
+	}
+
+	header := http.Header{}
+
+	header.Set("X-Node-Secret", env.Token)
+
+	c, _, err := websocket.DefaultDialer.Dial(url, header)
+	if err != nil {
+		return
+	}
+
+	defer c.Close()
+
+	for {
+		_, message, err := c.ReadMessage()
+		if err != nil {
+			return err
+		}
+		logger.Debugf("recv: %s %s", env.Name, message)
+
+		var nodeAnalytic Node
+
+		err = json.Unmarshal(message, &nodeAnalytic)
+
+		if err != nil {
+			return err
+		}
+
+		nodeAnalytic.EnvironmentID = env.ID
+		nodeAnalytic.Name = env.Name
+		// set online
+		nodeAnalytic.Status = true
+
+		NodeMap[env.ID] = &nodeAnalytic
+	}
+}
+
+func RetrieveNodesStatus() {
+	NodeMap = make(TNodeMap)
+
+	env := query.Environment
+
+	envs, err := env.Find()
+
+	if err != nil {
+		logger.Error(err)
+		return
+	}
+
+	errChan := make(chan error)
+
+	for _, v := range envs {
+		go nodeAnalyticLive(v, errChan)
+	}
+
+	// block at here
+	for err = range errChan {
+		log.Error(err)
+	}
+}

+ 43 - 0
server/model/environment.go

@@ -1,8 +1,51 @@
 package model
 
+import (
+	"net/url"
+	"strings"
+)
+
 type Environment struct {
 	Model
 	Name  string `json:"name"`
 	URL   string `json:"url"`
 	Token string `json:"token"`
 }
+
+func (e *Environment) GetWebSocketURL(uri string) (decodedUri string, err error) {
+	baseUrl, err := url.Parse(e.URL)
+	if err != nil {
+		return
+	}
+
+	defaultPort := ""
+	if baseUrl.Port() == "" {
+		switch baseUrl.Scheme {
+		default:
+			fallthrough
+		case "http":
+			defaultPort = "80"
+		case "https":
+			defaultPort = "443"
+		}
+
+		baseUrl.Host = baseUrl.Hostname() + ":" + defaultPort
+	}
+
+	u, err := url.JoinPath(baseUrl.String(), uri)
+
+	if err != nil {
+		return
+	}
+
+	decodedUri, err = url.QueryUnescape(u)
+
+	if err != nil {
+		return
+	}
+
+	// http will be replaced with ws, https will be replaced with wss
+	decodedUri = strings.ReplaceAll(decodedUri, "http", "ws")
+
+	return
+}

+ 49 - 83
server/router/proxy_ws.go

@@ -1,90 +1,56 @@
 package router
 
 import (
-    "github.com/0xJacky/Nginx-UI/server/internal/logger"
-    "github.com/0xJacky/Nginx-UI/server/query"
-    "github.com/gin-gonic/gin"
-    "github.com/pretty66/websocketproxy"
-    "github.com/spf13/cast"
-    "net/http"
-    "net/url"
-    "strings"
+	"github.com/0xJacky/Nginx-UI/server/internal/logger"
+	"github.com/0xJacky/Nginx-UI/server/query"
+	"github.com/gin-gonic/gin"
+	"github.com/pretty66/websocketproxy"
+	"github.com/spf13/cast"
+	"net/http"
 )
 
 func proxyWs() gin.HandlerFunc {
-    return func(c *gin.Context) {
-        nodeID, ok := c.Get("ProxyNodeID")
-        if !ok {
-            c.Next()
-            return
-        }
-        id := cast.ToInt(nodeID)
-        if id == 0 {
-            c.Next()
-            return
-        }
-
-        defer c.Abort()
-
-        env := query.Environment
-        environment, err := env.Where(env.ID.Eq(id)).First()
-
-        if err != nil {
-            logger.Error(err)
-            return
-        }
-
-        baseUrl, err := url.Parse(environment.URL)
-        if err != nil {
-            logger.Error(err)
-            return
-        }
-
-        logger.Debug(baseUrl.Port())
-        defaultPort := ""
-        if baseUrl.Port() == "" {
-            switch baseUrl.Scheme {
-            default:
-                fallthrough
-            case "http":
-                defaultPort = "80"
-            case "https":
-                defaultPort = "443"
-            }
-
-            baseUrl.Host = baseUrl.Hostname() + ":" + defaultPort
-        }
-        logger.Debug(baseUrl.String())
-
-        u, err := url.JoinPath(baseUrl.String(), c.Request.RequestURI)
-
-        if err != nil {
-            logger.Error(err)
-            return
-        }
-
-        decodedUri, err := url.QueryUnescape(u)
-
-        if err != nil {
-            logger.Error(err)
-            return
-        }
-
-        // http will be replaced with ws, https will be replaced with wss
-        decodedUri = strings.ReplaceAll(decodedUri, "http", "ws")
-
-        logger.Debug("Proxy request", decodedUri)
-
-        wp, err := websocketproxy.NewProxy(decodedUri, func(r *http.Request) error {
-            r.Header.Set("X-Node-Secret", environment.Token)
-            return nil
-        })
-
-        if err != nil {
-            logger.Error(err)
-            return
-        }
-
-        wp.Proxy(c.Writer, c.Request)
-    }
+	return func(c *gin.Context) {
+		nodeID, ok := c.Get("ProxyNodeID")
+		if !ok {
+			c.Next()
+			return
+		}
+		id := cast.ToInt(nodeID)
+		if id == 0 {
+			c.Next()
+			return
+		}
+
+		defer c.Abort()
+
+		env := query.Environment
+		environment, err := env.Where(env.ID.Eq(id)).First()
+
+		if err != nil {
+			logger.Error(err)
+			return
+		}
+
+		decodedUri, err := environment.GetWebSocketURL(c.Request.RequestURI)
+
+		if err != nil {
+			logger.Error(err)
+			return
+		}
+
+		logger.Debug("Proxy request", decodedUri)
+
+		wp, err := websocketproxy.NewProxy(decodedUri, func(r *http.Request) error {
+			r.Header.Set("X-Node-Secret", environment.Token)
+			return nil
+		})
+
+		if err != nil {
+			logger.Error(err)
+			return
+		}
+
+		wp.Proxy(c.Writer, c.Request)
+	}
 }

+ 2 - 0
server/router/routers.go

@@ -35,6 +35,8 @@ func InitRouter() *gin.Engine {
 		{
 			// Analytic
 			w.GET("analytic", api.Analytic)
+			w.GET("analytic/intro", api.GetIntroAnalytic)
+			w.GET("analytic/nodes", api.GetNodesAnalytic)
 			// pty
 			w.GET("pty", api.Pty)
 			// Nginx log

+ 1 - 0
server/server.go

@@ -53,6 +53,7 @@ func Program(state overseer.State) {
 	s.StartAsync()
 
 	go analytic.RecordServerAnalytic()
+	go analytic.RetrieveNodesStatus()
 
 	err = http.Serve(state.Listener, router.InitRouter())
 	if err != nil {

+ 1 - 0
server/service/environment.go

@@ -47,6 +47,7 @@ type NodeInfo struct {
 	Version           string      `json:"version"`
 	CPUNum            int         `json:"cpu_num"`
 	MemoryTotal       string      `json:"memory_total"`
+	DiskTotal         string      `json:"disk_total"`
 	ResponseAt        time.Time   `json:"response_at"`
 }