瀏覽代碼

feat: 增加Agent代理服务-90%
minor: docker构建脚本

tuonian 1 天之前
父節點
當前提交
ea78dc939d

+ 17 - 10
agent.go

@@ -6,8 +6,24 @@ import (
 	"nginx-ui/agent"
 	"nginx-ui/server/models"
 	"os"
+	"time"
 )
 
+func look(serverUrl string, ssl string, token string) {
+	for {
+		c := agent.NewAgent(serverUrl, ssl, token)
+		c.SetMessageHandler(models.NginxUpdateType, agent.OnNginxUpdated)
+		c.SetMessageHandler(models.AgentCmdType, agent.OnCMD)
+		c.SetMessageHandler(models.SendFileType, agent.OnSendFile)
+		c.SetMessageHandler(models.ServerConnected, agent.OnServerConnected)
+		err := c.Run()
+		if err != nil {
+			log.Println("agent err:", err)
+			time.Sleep(5 * time.Second)
+		}
+	}
+}
+
 // 代理服务
 func main() {
 
@@ -28,14 +44,5 @@ func main() {
 		token = &envToken
 	}
 	log.Printf("agent server url: %s", *serverUrl)
-	c := agent.NewAgent(*serverUrl, *ssl, *token)
-	c.SetMessageHandler(models.NginxUpdateType, agent.OnNginxUpdated)
-	c.SetMessageHandler(models.AgentCmdType, agent.OnCMD)
-	c.SetMessageHandler(models.SendFileType, agent.OnSendFile)
-	c.SetMessageHandler(models.ServerConnected, agent.OnServerConnected)
-	err := c.Run()
-	if err != nil {
-		log.Fatal(err)
-	}
-
+	look(*serverUrl, *ssl, *token)
 }

+ 81 - 89
agent/agent.go

@@ -26,70 +26,58 @@ const (
 	maxMessageSize = 1024
 )
 
-var mutex = &sync.Mutex{}
+// 消息写锁,不支持并发写消息
+var writeLock = &sync.Mutex{}
 
 // Agent 客户端的代码
 // - SSL Y or N
 type Agent struct {
-	Url               string `json:"url"`
-	Token             string `json:"token"`
-	SSL               string `json:"ssl"`
-	dialer            *websocket.Dialer
-	ReconnectInterval time.Duration
-	Ctx               context.Context
-	Cancel            context.CancelFunc
-	conn              *websocket.Conn
-	errChan           chan error
-	callbacks         map[string]chan *models.AgentData
-	handlers          map[string]func(agent *Agent, message *models.AgentData) (interface{}, error)
-	Nginx             *models.Nginx
+	Url       string `json:"url"`
+	Token     string `json:"token"`
+	SSL       string `json:"ssl"`
+	Nginx     *models.Nginx
+	Connected bool
+	callbacks map[string]chan *models.AgentData
+	handlers  map[string]func(agent *Agent, message *models.AgentData) (interface{}, error)
+	dialer    *websocket.Dialer
+	Ctx       context.Context
+	Cancel    context.CancelFunc
+	conn      *websocket.Conn
+	errChan   chan error
 }
 
 func NewAgent(url string, ssl string, token string) *Agent {
 	ctx, cancel := context.WithCancel(context.Background())
 	return &Agent{
-		Url:               url,
-		Token:             token,
-		SSL:               ssl,
-		dialer:            &websocket.Dialer{ReadBufferSize: 1024, WriteBufferSize: 1024},
-		ReconnectInterval: 10 * time.Second,
-		Ctx:               ctx,
-		Cancel:            cancel,
-		errChan:           make(chan error, 3),
-		callbacks:         make(map[string]chan *models.AgentData),
-		handlers:          make(map[string]func(agent *Agent, message *models.AgentData) (interface{}, error)),
+		Url:       url,
+		Token:     token,
+		SSL:       ssl,
+		callbacks: make(map[string]chan *models.AgentData),
+		handlers:  make(map[string]func(agent *Agent, message *models.AgentData) (interface{}, error)),
+		dialer:    &websocket.Dialer{ReadBufferSize: 1024, WriteBufferSize: 1024},
+		Ctx:       ctx,
+		Cancel:    cancel,
+		errChan:   make(chan error, 3),
 	}
 }
 
-func (a *Agent) SetMessageHandler(key string, handler func(agent *Agent, message *models.AgentData) (interface{}, error)) {
-	a.handlers[key] = handler
-}
-
 func (a *Agent) Reset() {
-	mutex.Lock()
-	defer mutex.Unlock()
-	for k, _ := range a.callbacks {
-		delete(a.callbacks, k)
-	}
+	a.Connected = false
 	a.Cancel() // 通知协程退出
+	if a.conn != nil {
+		_ = a.conn.Close()
+	}
+	ctx, cancel := context.WithCancel(context.Background())
+	a.Ctx = ctx
+	a.Cancel = cancel
 	log.Printf("reset")
 }
 
-func (a *Agent) setCallback(key string, c chan *models.AgentData) {
-	mutex.Lock()
-	defer mutex.Unlock()
-	a.callbacks[key] = c
-}
-
-func (a *Agent) removeCallback(key string) {
-	mutex.Lock()
-	defer mutex.Unlock()
-	delete(a.callbacks, key)
+func (a *Agent) SetMessageHandler(key string, handler func(agent *Agent, message *models.AgentData) (interface{}, error)) {
+	a.handlers[key] = handler
 }
 
 func (a *Agent) getCallback(key string) chan *models.AgentData {
-	mutex.Lock()
-	defer mutex.Unlock()
 	ret, ok := a.callbacks[key]
 	if ok {
 		return ret
@@ -145,8 +133,12 @@ func (a *Agent) read() {
 
 func (a *Agent) ping() {
 	log.Printf("ping start")
+	_ = a.conn.SetWriteDeadline(time.Now().Add(writeWait))
 	ticker := time.NewTicker(pingPeriod)
-	defer ticker.Stop()
+	defer func() {
+		ticker.Stop()
+		log.Printf("ping closed")
+	}()
 	for {
 		select {
 		case <-a.Ctx.Done():
@@ -172,45 +164,42 @@ func (a *Agent) Run() error {
 		protocol = "wss"
 	}
 	url := fmt.Sprintf("%v://%v%v", protocol, a.Url, constants.AgentConnectUrl)
-	for {
-		conn, _, err := a.dialer.Dial(url, map[string][]string{"token": {a.Token}})
-		if err != nil {
-			log.Printf("agent: failed to connect to %s: %s\n", a.Url, err)
-			time.Sleep(a.ReconnectInterval)
-			continue
-		}
-		log.Printf("agent: connected to %s\n", a.Url)
+	conn, _, err := a.dialer.Dial(url, map[string][]string{"token": {a.Token}})
+	if err != nil {
+		log.Printf("agent: failed to connect to %s: %s\n", a.Url, err)
+		return err
+	}
+	log.Printf("agent: connected to %s\n", a.Url)
 
-		conn.SetReadLimit(maxMessageSize)
+	conn.SetReadLimit(maxMessageSize)
+	_ = conn.SetReadDeadline(time.Now().Add(readWait))
+	conn.SetPongHandler(func(string) error {
+		log.Printf("pong")
 		_ = conn.SetReadDeadline(time.Now().Add(readWait))
-		conn.SetPongHandler(func(string) error {
-			log.Printf("pong")
-			_ = conn.SetReadDeadline(time.Now().Add(readWait))
-			return nil
-		})
-		a.conn = conn
-		go a.ping()
-		go a.read()
+		return nil
+	})
+	a.conn = conn
+	go a.ping()
+	go a.read()
+	a.Connected = true
 
-		handler := a.handlers[models.ServerConnected]
-		if handler != nil {
-			go func() {
-				_, _ = handler(a, &models.AgentData{
-					Type: models.ServerConnected,
-				})
-			}()
-		}
-		log.Printf("agent: waiting for closed")
-		// 等待错误或连接关闭
-		select {
-		case err := <-a.errChan:
-			log.Println("连接异常:", err)
-			a.Reset()
-			conn.Close() // 关闭当前连接
-			log.Println("连接已关闭,5秒后重连...")
-			time.Sleep(a.ReconnectInterval)
-		}
+	handler := a.handlers[models.ServerConnected]
+	if handler != nil {
+		go func() {
+			_, _ = handler(a, &models.AgentData{
+				Type: models.ServerConnected,
+			})
+		}()
+	}
+	log.Printf("agent: waiting for closed")
+	// 等待错误或连接关闭
+	select {
+	case err := <-a.errChan:
+		log.Println("连接异常:", err)
+		a.Reset()
+		log.Println("连接已关闭,5秒后重连...")
 	}
+	return errors.New("closed")
 }
 
 /*
@@ -226,20 +215,22 @@ Send 发送消息
 	error - 错误
 */
 func (a *Agent) Send(data *models.AgentData, timeout time.Duration) (*models.AgentData, error) {
-	var rec = make(chan *models.AgentData)
 	if len(data.RequestId) == 0 {
 		data.RequestId = utils.GenerateUUID()
 	}
 	requestId := data.RequestId
-	a.setCallback(requestId, rec)
 	timer := time.NewTimer(timeout)
+	var rec = make(chan *models.AgentData, 5)
+	a.callbacks[requestId] = rec
 	defer func() {
 		timer.Stop()
-		a.removeCallback(requestId)
-		log.Printf("release send chan")
+		delete(a.callbacks, requestId)
 		close(rec)
+		log.Printf("release send chan")
 	}()
+	writeLock.Lock()
 	err := a.conn.WriteJSON(data)
+	writeLock.Unlock()
 	if err != nil {
 		log.Printf("write message fail: %v", err)
 		return nil, err
@@ -248,13 +239,12 @@ func (a *Agent) Send(data *models.AgentData, timeout time.Duration) (*models.Age
 	for {
 		select {
 		case <-a.Ctx.Done():
-			return nil, errors.New("timeout")
+			return nil, errors.New("conn closed")
 		case res, ok := <-rec:
-			if !ok {
-				return nil, errors.New("receive data fail")
+			if ok {
+				return res, nil
 			}
-			return res, err
-
+			return nil, errors.New("receive data fail")
 		case <-timer.C:
 			return nil, errors.New("timeout")
 		}
@@ -263,6 +253,8 @@ func (a *Agent) Send(data *models.AgentData, timeout time.Duration) (*models.Age
 
 // JustSend 仅发送,不管是否成功,用于消息回复
 func (a *Agent) JustSend(message interface{}) {
+	writeLock.Lock()
+	defer writeLock.Unlock()
 	err := a.conn.WriteJSON(message)
 	log.Printf("JustSend: %v", err)
 }

+ 12 - 8
agent/handler.go

@@ -30,10 +30,10 @@ func OnNginxUpdated(c *Agent, data *models.AgentData) (interface{}, error) {
 }
 
 func OnServerConnected(c *Agent, data *models.AgentData) (interface{}, error) {
-	fmt.Printf("OnServerConnected: %v,%v\n", c.Token, data)
+	log.Printf("OnServerConnected: %v,%v\n", c.Token, data)
 	if c.Token == "" {
 		log.Warn("missing token")
-		return nil, nil
+		return nil, errors.New("missing token")
 	}
 	nginx := &models.Nginx{
 		Name:      "Agent",
@@ -54,7 +54,7 @@ func OnServerConnected(c *Agent, data *models.AgentData) (interface{}, error) {
 	res, err := c.Send(request, 20*time.Second)
 	if err != nil {
 		log.Warn("register fail for: %v\n", err)
-		return nil, nil
+		return nil, err
 	}
 	if res != nil {
 		err = res.ReadData(nginx)
@@ -64,7 +64,7 @@ func OnServerConnected(c *Agent, data *models.AgentData) (interface{}, error) {
 		}
 		c.Nginx = nginx
 	}
-	return nil, nil
+	return c.Nginx, nil
 }
 
 // OnCMD 执行Nginx命令
@@ -92,10 +92,14 @@ func OnCMD(c *Agent, data *models.AgentData) (interface{}, error) {
 func OnSendFile(c *Agent, data *models.AgentData) (interface{}, error) {
 	nginx := c.Nginx
 	if nginx == nil {
-		go func() {
-			_, _ = OnServerConnected(c, data)
-		}()
-		return nil, errors.New("nginx agent not register")
+		ngx, err := OnServerConnected(c, data)
+		if err != nil {
+			return nil, err
+		}
+		if ngx == nil {
+			return nil, errors.New("nginx agent not register")
+		}
+		nginx = ngx.(*models.Nginx)
 	}
 	request := &models.AgentSendFile{}
 	err := data.ReadData(request)

+ 1 - 1
build-nginx-with-ui.sh

@@ -3,4 +3,4 @@
 ver=$(date +%y%m%d%H%M)
 
 export DOCKER_BUILDKIT=1
-docker build . -f docker/Dockerfile-with-nginx  -t registry.cn-hangzhou.aliyuncs.com/tuon-pub/nginx-with-ui:$ver
+docker build . -f docker/Dockerfile-with-nginx -target=nginx  -t registry.cn-hangzhou.aliyuncs.com/tuon-pub/nginx-with-ui:$ver

+ 1 - 1
build.sh

@@ -2,4 +2,4 @@
 dos2unix docker/entrypoint.sh
 chmod +x *.sh
 export DOCKER_BUILDKIT=1
-docker build . -f docker/Dockerfile  -t registry.cn-hangzhou.aliyuncs.com/tuon-pub/nginx-ui:latest
+docker build . -f docker/Dockerfile -target=nginxUI  -t registry.cn-hangzhou.aliyuncs.com/tuon-pub/nginx-ui:latest

+ 32 - 3
docker/Dockerfile

@@ -7,9 +7,11 @@ ARG GOARCH=amd64
 RUN go env -w GOPROXY=https://goproxy.cn,direct
 
 RUN --mount=type=cache,target=/go --mount=type=cache,target=/root/.cache/go-build\
-    GOODS=${GOODS} GOARCH=${GOARCH} go build -o /app/app  app.go
+    GOODS=${GOODS} GOARCH=${GOARCH} go build -o /app/app  app.go && \
+    go build -o /app/agent agent.go
 
-FROM debian:sid-slim
+
+FROM debian:sid-slim as nginxUI
 
 RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/* && \
     sed -i 's/security.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/* && \
@@ -17,7 +19,8 @@ RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/* &&
 
 RUN mkdir -p /app/static/web
 WORKDIR /app
-COPY --from=builder /app/app /app
+COPY --from=builder /app/app .
+COPY --from=builder /app/agent .
 COPY conf /app/conf
 COPY data  /app/data
 #COPY ../server/static  /app/static
@@ -27,3 +30,29 @@ RUN chmod +x /app/app && rm -rf /app/static/web/config.js
 
 
 ENTRYPOINT ["/app/app"]
+
+# nginx-with-ui
+FROM registry.cn-hangzhou.aliyuncs.com/tuon-pub/nginx:1.25.1 as nginx
+
+RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/* && \
+    sed -i 's/security.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/* && \
+    apt -qq update && apt -qq install -y --no-install-recommends ca-certificates curl
+
+RUN mkdir -p /app
+WORKDIR /app
+COPY --from=builder /app/app .
+COPY --from=builder /app/agent .
+COPY conf /app/conf
+COPY data  /app/data
+#COPY ../server/static  /app/static
+COPY frontend/dist  /app/static/web
+COPY docker/entrypoint.sh /entrypoint.sh
+
+ENV TOKEN=""
+ENV SSL = "N"
+ENV SERVER_URL=""
+
+RUN chmod +x /entrypoint.sh /app/app && rm -rf /app/static/web/config.js && \
+    rm -rf /etc/nginx/conf.d
+
+ENTRYPOINT ["/entrypoint.sh"]

+ 0 - 30
docker/Dockerfile-with-nginx

@@ -1,30 +0,0 @@
-FROM golang:1.21 AS builder
-WORKDIR /app
-COPY . .
-ARG GOODS=linux
-ARG GOARCH=amd64
-
-RUN go env -w GOPROXY=https://goproxy.cn,direct
-
-RUN --mount=type=cache,target=/go --mount=type=cache,target=/root/.cache/go-build \
-    GOODS=${GOODS} GOARCH=${GOARCH}  go build -o /app/app  app.go
-
-FROM registry.cn-hangzhou.aliyuncs.com/tuon-pub/nginx:1.25.1
-
-RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/* && \
-    sed -i 's/security.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/* && \
-    apt -qq update && apt -qq install -y --no-install-recommends ca-certificates curl
-
-RUN mkdir -p /app
-WORKDIR /app
-COPY --from=builder /app/app /app
-COPY conf /app/conf
-COPY data  /app/data
-#COPY ../server/static  /app/static
-COPY frontend/dist  /app/static/web
-COPY docker/entrypoint.sh /entrypoint.sh
-
-RUN chmod +x /entrypoint.sh /app/app && rm -rf /app/static/web/config.js && \
-    rm -rf /etc/nginx/conf.d
-
-ENTRYPOINT ["/entrypoint.sh"]

+ 5 - 1
docker/entrypoint.sh

@@ -12,5 +12,9 @@ if [ -f "$dataDir/nginx.conf" ];then
 fi
 #nginx -g "daemon on;"
 ## 启用 nginx-ui的服务
-nohup /app/app & > /app/logs.log
+if [ "${SERVER_URL}x" = "x" ]; then
+    nohup /app/app  > /app/logs.log 2 >&1 &
+else
+    nohup /app/agent >/app/agent.log 2>&1 &
+fi
 nginx -g "daemon off;"

文件差異過大導致無法顯示
+ 0 - 1
frontend/dist/assets/index-BDKWdQ_l.js


文件差異過大導致無法顯示
+ 1 - 0
frontend/dist/assets/index-CcxoRunz.js


文件差異過大導致無法顯示
+ 0 - 0
frontend/dist/assets/index-n2V1fyyW.css


+ 1 - 1
frontend/dist/cdn/rich.js

@@ -1,2 +1,2 @@
-import{a as f,R as w,w as D}from"./react-all-18.2.0.js";import"./ace-builds-1.23.0.js";import{j as S}from"../assets/index-BDKWdQ_l.js";var b=function(t){var e;return typeof t=="string"||!t||isNaN(t)?e=t:e="".concat(t,"px"),e};function j(r,t){return x(r)||E(r,t)||T(r,t)||I()}function I(){throw new TypeError(`Invalid attempt to destructure non-iterable instance.
+import{a as f,R as w,w as D}from"./react-all-18.2.0.js";import"./ace-builds-1.23.0.js";import{j as S}from"../assets/index-CcxoRunz.js";var b=function(t){var e;return typeof t=="string"||!t||isNaN(t)?e=t:e="".concat(t,"px"),e};function j(r,t){return x(r)||E(r,t)||T(r,t)||I()}function I(){throw new TypeError(`Invalid attempt to destructure non-iterable instance.
 In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function T(r,t){if(r){if(typeof r=="string")return O(r,t);var e=Object.prototype.toString.call(r).slice(8,-1);if(e==="Object"&&r.constructor&&(e=r.constructor.name),e==="Map"||e==="Set")return Array.from(r);if(e==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return O(r,t)}}function O(r,t){(t==null||t>r.length)&&(t=r.length);for(var e=0,o=new Array(t);e<t;e++)o[e]=r[e];return o}function E(r,t){var e=r==null?null:typeof Symbol<"u"&&r[Symbol.iterator]||r["@@iterator"];if(e!=null){var o,i,u,l,n=[],a=!0,s=!1;try{if(u=(e=e.call(r)).next,t!==0)for(;!(a=(o=u.call(e)).done)&&(n.push(o.value),n.length!==t);a=!0);}catch(c){s=!0,i=c}finally{try{if(!a&&e.return!=null&&(l=e.return(),Object(l)!==l))return}finally{if(s)throw i}}return n}}function x(r){if(Array.isArray(r))return r}var L=function(t,e){return!e||!t.saveAsObj?e:t.mode==="json"?JSON.parse(e):t.mode==="yaml"||t.mode==="yml"?S.load(e):e},M=function(t,e){return typeof e=="string"||!e?e:t.mode==="json"?JSON.stringify(e,null,2):t.mode==="yaml"||t.mode==="yml"?S.dump(e):e};const k=function(r){var t=r.value,e=r.className,o=e===void 0?"":e,i=r.column,u=r.onChange,l=r.readonly,n=i,a=n.mode;a==="yml"?a="yaml":a||(a="json");var s=f.useState(),c=j(s,2),A=c[0],y=c[1],C=f.useState(""),h=j(C,2),N=h[0],p=h[1],m=f.useRef(),R=function(v){y(v);try{var g=L(n,v);m.current=g,u==null||u(g),p("")}catch{p("has-err")}};return f.useEffect(function(){if(!(m.current&&t===m.current))try{y(M(n,t))}catch(d){console.warn("fromObjData: ",d)}},[t]),w.createElement(D,{mode:a,value:A,showPrintMargin:n.printMargin,showGutter:!n.hiddenLines,highlightActiveLine:!0,theme:"chrome",height:b(i.height||120),width:b(i.width)||"80%",onChange:R,readOnly:!!i.disabled||l,setOptions:n.options,className:"rich-input json-input ".concat(o," ").concat(N),placeholder:n.placeholder})};export{k as default,M as fromObjData,L as toObjData};

+ 2 - 2
frontend/dist/index.html

@@ -6,10 +6,10 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>NginxUI</title>
     <script type="application/javascript" src="/nginx-ui/config.js"></script>
-    <script type="module" crossorigin src="/nginx-ui/assets/index-BDKWdQ_l.js"></script>
+    <script type="module" crossorigin src="/nginx-ui/assets/index-CcxoRunz.js"></script>
     <link rel="modulepreload" crossorigin href="/nginx-ui/cdn/ace-builds-1.23.0.js">
     <link rel="modulepreload" crossorigin href="/nginx-ui/cdn/react-all-18.2.0.js">
-    <link rel="stylesheet" crossorigin href="/nginx-ui/assets/index-CdH8Zg1S.css">
+    <link rel="stylesheet" crossorigin href="/nginx-ui/assets/index-n2V1fyyW.css">
   </head>
   <body>
     <div id="nginx_ui_root"></div>


+ 6 - 3
frontend/src/components/curd/Form.tsx

@@ -1,5 +1,5 @@
 import {Button} from "antd";
-import {ForwardedRef, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState} from "react";
+import React, {ForwardedRef, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState} from "react";
 import {AutoForm, AutoFormInstance, isNull, isNullOrTrue, Message} from "auto-antd";
 import {CurdColumn} from "./index.tsx";
 import {FormColumnType} from "auto-antd/dist/esm/Model";
@@ -7,7 +7,7 @@ import {ICurdConfig, ICurdData} from "./types.ts";
 import './form.less'
 
 
-export type IProps<D> = {
+export type CurdFormProps<D> = {
     config?: ICurdConfig<D>
     columns: CurdColumn[]
     onSuccess?: (data: Partial<D>) => void
@@ -15,6 +15,7 @@ export type IProps<D> = {
     onClose?: () => void
     initialData?: Partial<D>
     editable?: boolean
+    suffix?: React.ReactNode
 }
 
 export type ICurdForm<D> = {
@@ -22,7 +23,8 @@ export type ICurdForm<D> = {
     setData: (data: Partial<D>) => void
 }
 
-const Form = <D extends ICurdData,>({config, initialData, onClose, editable,...props}: IProps<D>,ref: ForwardedRef<ICurdForm<D>>) => {
+const Form = <D extends ICurdData,>(
+    {config, initialData, onClose, editable,suffix,...props}: CurdFormProps<D>, ref: ForwardedRef<ICurdForm<D>>) => {
 
 
     const [loading,setLoading] = useState<boolean>(false);
@@ -90,6 +92,7 @@ const Form = <D extends ICurdData,>({config, initialData, onClose, editable,...p
                           }
                       }}
                       ref={formRef as any}/>
+            {suffix}
             <div className="form-container-footer">
                 <Button hidden={!isNullOrTrue(editable)} onClick={onSubmit} loading={loading} type="primary">保存</Button>
                 <Button onClick={onClose} danger>取消</Button>

+ 1 - 1
frontend/src/components/curd/index.tsx

@@ -2,7 +2,7 @@ import {AutoColumn, AutoText, isNullOrTrue, Message} from "auto-antd";
 import {Button, Drawer, Modal, Switch, Table, TableProps} from "antd";
 import React, {useEffect, useMemo, useRef, useState} from "react";
 import {ColumnsType} from "antd/lib/table";
-import {CurdForm, ICurdForm, IProps as IFormProps} from "./Form.tsx";
+import {CurdForm, ICurdForm, CurdFormProps as IFormProps} from "./Form.tsx";
 import {LDAP} from "../../api/ldap.ts";
 import {DeleteOutlined, EditOutlined, LoadingOutlined, PlusOutlined, SyncOutlined} from "@ant-design/icons";
 import './index.less'

+ 1 - 0
frontend/src/pages/nginx/components/site/index.tsx

@@ -57,6 +57,7 @@ export const SiteInput = ({ location, onChange }: IProps) => {
           }
         }
         setEditData(initialData)
+        form?.setFieldsValue(initialData)
     }
 
     const updateLocation = (deployData: IDeployReq) => {

+ 190 - 161
frontend/src/pages/nginx/list.tsx

@@ -2,7 +2,7 @@
  * @author tuonian
  * @date 2023/6/26
  */
-import {Button, Modal, Table, TableColumnsType, Tag} from 'antd'
+import {Button, Modal, Table, TableColumnsType, Tabs, Tag} from 'antd'
 import {useEffect, useRef, useState} from "react";
 import NginxDemo from './nginx.json'
 import {INginx} from "../../models/nginx.ts";
@@ -13,175 +13,204 @@ import {DeleteOutlined, EditOutlined, PlusOutlined, SyncOutlined} from "@ant-des
 import {useNavigate} from "react-router";
 import {useAppDispatch} from "../../store";
 import {NginxActions} from "../../store/slice/nginx.ts";
+import {TokenAdd} from "../user/token/add.tsx";
+import {TokenListWithDrawer} from "../user/token";
+
+export const NginxList = () => {
+
+
+    const [loading, setLoading] = useState(false)
+    const [nginxList, setNginxList] = useState<INginx[]>([NginxDemo as any])
+    const [open, setOpen] = useState(false)
+    const [activeTab, setActiveTab] = useState<string>('mutual')
+    const formRef = useRef<AutoFormInstance>()
+
+    const [modal, contextHolder] = Modal.useModal()
+
+    const formConfig = useFormConfig();
+    const navigate = useNavigate();
+    const dispatch = useAppDispatch();
+
+    const fetchData = () => {
+        setLoading(true)
+        NginxApis.findAll()
+            .then(({data}) => {
+                if (Array.isArray(data.data)) {
+                    setNginxList(data.data)
+                }
+                console.log('data', data)
+            })
+            .catch(e => {
+                console.log('fetchData fail', e)
+            })
+            .finally(() => {
+                setLoading(false)
+            })
 
-export const NginxList = ()=>{
-
+    }
 
-  const [loading,setLoading] = useState(false)
-  const [nginxList,setNginxList] = useState<INginx[]>([NginxDemo as any])
-  const [open,setOpen] = useState(false)
-  const formRef = useRef<AutoFormInstance>()
+    const closeDialog = () => {
+        setOpen(false)
+        fetchData()
+    }
 
-  const [modal,contextHolder] = Modal.useModal()
+    const onAddNginx = async () => {
+        if (activeTab === 'agent') {
+            closeDialog()
+            return;
+        }
+        const values = await formRef.current?.onSyncSubmit(true);
+        console.log('onAdd', values);
+        setLoading(true);
+        NginxApis.updateOrAdd(values)
+            .then(({data}) => {
+                console.log('addNginx', data)
+                Message.success('添加成功!')
+                closeDialog()
+            })
+            .catch(e => {
+                console.log('add fail', e)
+                if (e.code == 1) {
+                    Notify.warn(`实例添加成功,但环境检查失败:${e.msg || e.message}`);
+                    closeDialog()
+                } else {
+                    Notify.warn(e.msg || e.message)
+                }
+            })
+            .finally(() => {
+                setLoading(false)
+            })
+    }
 
-  const formConfig = useFormConfig();
-  const navigate = useNavigate();
-  const dispatch = useAppDispatch();
+    const onRemoveNginx = (data: INginx) => {
+        modal.confirm({
+            title: '警告',
+            content: '您确认要删除该实例吗?该操作不可恢复,请谨慎操作;删除实例不会影响服务器现有的文件和状态',
+            okType: 'danger',
+            okText: '确认删除',
+            cancelText: '先不了',
+            onOk: () => {
+                NginxApis.delNginx(data.id)
+                    .then(() => {
+                        fetchData()
+                    })
+                    .catch(e => {
+                        Notify.warn(e.msg || e.message)
+                    })
+            }
+        })
+    }
 
-  const fetchData = ()=>{
-    setLoading(true)
-    NginxApis.findAll()
-      .then(({data})=>{
-        if (Array.isArray(data.data)){
-          setNginxList(data.data)
-        }
-        console.log('data',data)
-      })
-      .catch(e=>{
-        console.log('fetchData fail',e)
-      })
-      .finally(()=>{
-        setLoading(false)
-      })
-
-  }
-
-  const onAddNginx = async () =>{
-    const values = await formRef.current?.onSyncSubmit(true);
-    console.log('onAdd', values);
-    setLoading(true);
-    NginxApis.updateOrAdd(values)
-      .then(({data})=>{
-        console.log('addNginx', data)
-        Message.success('添加成功!')
-        setOpen(false)
+    useEffect(() => {
         fetchData()
-      })
-      .catch(e=>{
-        console.log('add fail', e)
-        if (e.code == 1){
-          fetchData()
-          Notify.warn(`实例添加成功,但环境检查失败:${e.msg || e.message}`);
-          setOpen(false)
-        }else {
-          Notify.warn(e.msg || e.message)
-        }
-      })
-      .finally(()=>{
-        setLoading(false)
-      })
-  }
-
-  const onRemoveNginx = (data: INginx) =>{
-    modal.confirm({
-      title: '警告',
-      content: '您确认要删除该实例吗?该操作不可恢复,请谨慎操作;删除实例不会影响服务器现有的文件和状态',
-      okType: 'danger',
-      okText: '确认删除',
-      cancelText: '先不了',
-      onOk: ()=>{
-        NginxApis.delNginx(data.id)
-          .then(()=>{
-            fetchData()
-          })
-          .catch(e=>{
-            Notify.warn(e.msg || e.message)
-          })
-      }
-    })
-  }
-
-  useEffect(()=>{
-    fetchData()
-  },[])
-
-  const toNginx = (nginx: INginx) => {
-    dispatch(NginxActions.reset())
-    navigate(`/nginx/${nginx.id}`)
-  }
-
-  const renderOperations = (data: INginx)=>{
-
-    return (<>
-      <Button onClick={()=>toNginx(data)} type="link"><EditOutlined /></Button>
-      <Button onClick={()=>onRemoveNginx(data)} danger type="text" icon={<DeleteOutlined />}/>
-    </>)
-
-  }
-
-  const columns = [
-    {
-      dataIndex: 'id',
-      title: 'ID',
-    },
-    {
-      dataIndex: 'name',
-      title:"名称"
-    },
-    {
-      dataIndex: 'isLocal',
-      title: '实例类型',
-      render: (value,record) => {
-        if (record.proxy){
-          return (<Tag color="blue">Agent</Tag>)
-        }
-        return value ? '本地实例':<Tag color="orange">远程实例</Tag>
-      }
-    },
-    {
-      dataIndex: 'ipAddr',
-      title:'实例IP',
-      render: (value,record) => record.isLocal ? '--': value
-    },
-    {
-      dataIndex: 'remark',
-      title:'备注信息'
-    },
-    {
-      title: '操作',
-      render: (_,record)=>renderOperations(record),
-      width: 180,
-      fixed: 'right'
+    }, [])
+
+    const toNginx = (nginx: INginx) => {
+        dispatch(NginxActions.reset())
+        navigate(`/nginx/${nginx.id}`)
     }
 
-  ] as TableColumnsType<INginx>
+    const renderOperations = (data: INginx) => {
 
-  console.log('list' ,nginxList)
+        return (<>
+            <Button onClick={() => toNginx(data)} type="link"><EditOutlined/></Button>
+            <Button onClick={() => onRemoveNginx(data)} danger type="text" size="small" icon={<DeleteOutlined/>}/>
+        </>)
+
+    }
+
+    const columns = [
+        {
+            dataIndex: 'id',
+            title: 'ID',
+            align: 'center',
+        },
+        {
+            dataIndex: 'name',
+            title: "名称"
+        },
+        {
+            dataIndex: 'isLocal',
+            title: '实例类型',
+            render: (value, record) => {
+                if (record.proxy) {
+                    return (<Tag color="blue">Agent</Tag>)
+                }
+                return value ? '本地实例' : <Tag color="orange">远程实例</Tag>
+            }
+        },
+        {
+            dataIndex: 'ipAddr',
+            title: '实例IP',
+            render: (value, record) => record.isLocal ? '--' : value
+        },
+        {
+            dataIndex: 'remark',
+            title: '备注信息'
+        },
+        {
+            title: '操作',
+            render: (_, record) => renderOperations(record),
+            width: 120,
+            fixed: 'right'
+        }
 
-  return (
-    <div className="page">
-      <div className="page-header">
-        <div>
-          Nginx实例
+    ] as TableColumnsType<INginx>
+
+    console.log('list', nginxList)
+
+
+    return (
+        <div className="page">
+            <div className="page-header">
+                <div>
+                    Nginx实例
+                </div>
+                <div>
+                    <Button loading={loading} onClick={() => fetchData()} icon={<SyncOutlined/>}>刷新</Button>
+                    <Button type="primary" loading={loading} onClick={() => setOpen(true)} icon={<PlusOutlined/>}>添加</Button>
+                    <TokenListWithDrawer />
+                </div>
+            </div>
+            <div className="page-container">
+                <Table
+                    dataSource={nginxList}
+                    columns={columns as any}
+                    rowKey="id"
+                    bordered={true}
+                    size="small"
+                >
+                </Table>
+            </div>
+            <Modal title="新增实例"
+                   open={open}
+                   destroyOnClose={true}
+                   maskClosable={false}
+                   onCancel={() => setOpen(false)}
+                   onOk={onAddNginx}
+                   confirmLoading={loading}
+                   width="800px"
+                   bodyStyle={{ paddingTop: 0 }}
+                   footer={activeTab == 'agent' ? false: undefined}
+            >
+                <Tabs
+                    activeKey={activeTab}
+                    onChange={setActiveTab}
+                    items={[
+                        {
+                            label: '手动添加',
+                            key: 'mutual',
+                            children: <AutoForm data={{isLocal: true}} columns={formConfig.addNginx} ref={formRef as never}/>
+                        },
+                        {
+                            label: 'Agent注册',
+                            key: 'agent',
+                            children: <TokenAdd />
+                        }
+                    ]}
+                />
+            </Modal>
+            {contextHolder}
         </div>
-       <div>
-         <Button loading={loading} onClick={()=>fetchData()} icon={<SyncOutlined />} />
-         <Button type="primary" loading={loading} onClick={()=>setOpen(true)}  icon={<PlusOutlined />}/>
-       </div>
-
-      </div>
-      <div className="page-container">
-        <Table
-          dataSource={nginxList}
-          columns={columns as any}
-          rowKey="id"
-        >
-        </Table>
-      </div>
-      <Modal title="新增实例"
-      open={open}
-             destroyOnClose={true}
-             maskClosable={false}
-             onCancel={()=>setOpen(false)}
-             onOk={onAddNginx}
-             confirmLoading={loading}
-             width="800px"
-      >
-        <AutoForm data={{isLocal: true}}
-                  columns={formConfig.addNginx}
-                  ref={formRef as never} />
-      </Modal>
-      {contextHolder}
-    </div>
-  )
+    )
 }

+ 63 - 0
frontend/src/pages/user/token/add.tsx

@@ -0,0 +1,63 @@
+import {Button, FormInstance, Modal, ModalProps} from "antd";
+import {PlusOutlined} from "@ant-design/icons";
+import {CurdForm, CurdFormProps} from "../../../components/curd/Form.tsx";
+import React from "react";
+import {User, userTokenApis} from "../../../api/user.ts";
+import {TokenColumns} from "./constants.tsx";
+import moment from "moment";
+
+type IProps = Partial<CurdFormProps<any>> & {
+    layoutType?: 'Modal' | 'Form'
+    trigger?: React.ReactNode
+    modalProps?: Partial<ModalProps>
+}
+
+export const TokenAdd = (
+    {layoutType, trigger, modalProps, ...rest}: IProps
+) => {
+
+    const [show, setShow] = React.useState(false);
+    const formRef = React.useRef<FormInstance>(null);
+    const [modelValue, setModelValue] = React.useState<any>({
+        enabled: true,
+        name: 'Agent',
+        expiredAt: moment().add(10, 'years').unix()
+    });
+
+
+    const onSave = (data: Partial<User.AuthToken>) => {
+        return userTokenApis.create(data)
+            .then(resp => {
+                setModelValue(resp)
+                return resp;
+            })
+    }
+
+    const renderForm = () => {
+        return (
+            <>
+                <CurdForm
+                    columns={TokenColumns}
+                    ref={formRef as any}
+                    {...rest}
+                    onSave={onSave}
+                    config={{labelSpan: 4}}
+                    initialData={modelValue}
+                />
+            </>
+        )
+    }
+
+    if (layoutType === 'Modal') {
+        return (
+            <>
+                <span onClick={() => setShow(true)}>{trigger ? trigger :
+                    <Button size="small" icon={<PlusOutlined/>}></Button>}</span>
+                <Modal title="添加" {...modalProps} open={show} footer={false}>
+                    {renderForm()}
+                </Modal>
+            </>
+        )
+    }
+    return renderForm()
+}

+ 61 - 0
frontend/src/pages/user/token/constants.tsx

@@ -0,0 +1,61 @@
+import {CurdColumn} from "../../../components/curd";
+import {DateInput} from "../../../components/form/date";
+import moment from "moment/moment";
+
+export const TokenColumns: CurdColumn[] = [
+    {
+        key: 'id',
+        title: 'ID',
+        type: 'number',
+        required: true,
+        width: 50,
+        addable: false,
+        editable: false,
+    },
+    {
+        key: 'name',
+        title: '名称',
+        type: 'string',
+        editable: true,
+        placeholder: 'Token名称',
+        required: true,
+        addable: true,
+    },
+    {
+        key: 'token',
+        title: 'TOKEN',
+        type: 'string',
+        required: true,
+        placeholder: '静态access_token',
+        editable: false,
+        addable: false,
+    },
+    {
+        key: 'enabled',
+        title: '是否启用',
+        type: 'switch',
+        required: true,
+        width: 80
+    },
+    {
+        key: 'expiredAt',
+        title: '过期时间',
+        type: 'date',
+        width: 100,
+        renderInput: DateInput,
+        render: (value: any) => {
+            if (!value || isNaN(value)) return <span>-</span>;
+            return (
+                <span>{moment(value * 1000).format("YYYY-MM-DD")}</span>
+        )
+        }
+    },
+    {
+        key: 'remark',
+        type: 'textarea',
+        title: '备注',
+        value: '',
+        required: false,
+        rows: 2
+    }
+]

+ 30 - 69
frontend/src/pages/user/token/index.tsx

@@ -1,91 +1,38 @@
-import {useCallback, useMemo, useState} from "react";
-import {Alert} from "antd";
+import React, {useMemo, useState} from "react";
+import {Alert, Button, Drawer} from "antd";
 import './index.less'
-import {CurdColumn, CurdPage} from "../../../components/curd";
+import {CurdPage} from "../../../components/curd";
 import {ICurdConfig} from "../../../components/curd/types.ts";
 import {PageData} from "../../../models/api.ts";
 import {User, userTokenApis} from "../../../api/user.ts";
-import {DateInput} from "../../../components/form/date";
-import moment from "moment";
-
-const columns: CurdColumn[] = [
-    {
-        key: 'id',
-        title: 'ID',
-        type: 'number',
-        required: true,
-        width: 120,
-        addable: false,
-        editable: false,
-    },
-    {
-        key: 'name',
-        title: '名称',
-        type: 'string',
-        editable: true,
-        placeholder: 'Token名称',
-        required: true,
-        addable: true,
-    },
-    {
-        key: 'token',
-        title: 'TOKEN',
-        type: 'string',
-        required: true,
-        placeholder: '静态access_token',
-        editable: false,
-        addable: false,
-    },
-    {
-        key: 'enabled',
-        title: '是否启用',
-        type: 'switch',
-        required: true,
-        width: 100
-    },
-    {
-        key: 'expiredAt',
-        title: '过期时间',
-        type: 'date',
-        renderInput: DateInput,
-        render: (value: any) => {
-            if (!value || isNaN(value)) return <span>-</span>;
-            return (
-                <span>{moment(value * 1000).format("YYYY-MM-DD")}</span>
-            )
-        }
-    },
-    {
-        key: 'remark',
-        type: 'textarea',
-        title: '备注',
-        value: '',
-        required: false,
-        rows: 4
-    }
-]
+import {TokenColumns} from "./constants.tsx";
+
 
 const serverConfig: ICurdConfig<User.Role> = {
     editDialogWidth: 550,
     labelSpan: 4,
     hideAdd: false,
     bordered: true,
-    operationWidth: 120,
+    operationWidth: 80,
 }
 
+type IProps = {
+    admin?: boolean;
+}
 /**
  * 用户列表的操作
  * @constructor
  */
-export const List = () => {
+export const TokenList = (
+    {admin = true}: IProps
+) => {
 
     const [success, setSuccess] = useState('')
 
-    const getList = useCallback((query: any) => {
-
+    const getList = (query: any) => {
         return userTokenApis.getList({
             ...query,
-            uid: 0,
+            ...(admin ? {uid: 0} : {})
         }).then(res => {
             console.log('res', res)
             return {
@@ -95,7 +42,7 @@ export const List = () => {
                 pageSize: 1000,
             } as PageData<User.AuthToken>
         })
-    }, [])
+    }
 
     const getDetail = (data: Partial<User.AuthToken>) => {
         return Promise.resolve({...data} as User.AuthToken)
@@ -126,7 +73,7 @@ export const List = () => {
             success ? (<Alert type="success" message={success} style={{margin: 5}} closable={true}
                               onClose={() => setSuccess('')}/>) : null
         }
-        <CurdPage columns={columns}
+        <CurdPage columns={TokenColumns}
                   getList={getList}
                   getDetail={getDetail}
                   operationRender={<></>}
@@ -137,3 +84,17 @@ export const List = () => {
         />
     </>)
 }
+
+
+export const TokenListWithDrawer = () => {
+    const [open, setOpen] = React.useState<boolean>(false)
+
+    return (<>
+        <Button onClick={() => setOpen(true)}>我的TOKEN</Button>
+        <Drawer open={open} onClose={() => setOpen(false)} title="我的TOKEN"
+                bodyStyle={{paddingTop: 0}}
+                width={850}>
+            <TokenList admin={false}/>
+        </Drawer>
+    </>)
+}

+ 1 - 1
frontend/src/routes/routes.tsx

@@ -5,7 +5,7 @@ import {NginxLayout} from "../pages/nginx/layout.tsx";
 import {ldapRoutes} from "../pages/ldap/layout.tsx";
 import {UserList} from "../pages/user/list";
 import {List as RoleList} from "../pages/user/role";
-import { List as TokenList } from '../pages/user/token'
+import { TokenList as TokenList } from '../pages/user/token'
 import {RouteObject} from "react-router/dist/lib/context";
 import {SettingPage} from "../pages/settings";
 import {ResetPassword} from "../pages/user/resetPassword";

+ 3 - 0
frontend/src/styles/index.less

@@ -56,6 +56,9 @@
 .page-container{
   flex: 1;
   overflow: auto;
+  box-sizing: border-box;
+  padding: 1rem;
+  border-radius: .5rem;
   margin-bottom: 10px;
   .auto-form{
     height: auto;

+ 27 - 1
server/models/agent.go

@@ -1,6 +1,11 @@
 package models
 
-import "github.com/mitchellh/mapstructure"
+import (
+	"encoding/json"
+	"github.com/mitchellh/mapstructure"
+	"log"
+	"strconv"
+)
 
 const (
 	AgentCmdType      = "AGENT_CMD_TYPE"
@@ -29,6 +34,27 @@ func (r *AgentData) ReadData(result interface{}) error {
 	return err
 }
 
+func (r *AgentData) ReadStringData() string {
+	msg, ok := r.Data.(string)
+	if ok {
+		return msg
+	}
+	b, ok := r.Data.([]byte)
+	if ok {
+		return string(b)
+	}
+	i, ok := r.Data.(int)
+	if ok {
+		return strconv.Itoa(i)
+	}
+	b, err := json.Marshal(r.Data)
+	if err != nil {
+		log.Printf("read string data error: %v", err)
+		return ""
+	}
+	return string(b)
+}
+
 type AgentCMD struct {
 	Cmd string `json:"cmd"`
 }

+ 10 - 2
server/modules/agent_server/hub.go

@@ -1,6 +1,8 @@
 package agent_server
 
-import "nginx-ui/server/models"
+import (
+	"nginx-ui/server/models"
+)
 
 // Hub maintains the set of active clients and broadcasts messages to the
 // clients.
@@ -40,12 +42,16 @@ func (h *Hub) run() {
 	for {
 		select {
 		case client := <-h.register:
+			old, ok := h.clientMap[client.Token]
+			if ok {
+				old.Close()
+			}
 			h.clientMap[client.Token] = client
 		case client := <-h.unregister:
 			if _, ok := h.clientMap[client.Token]; ok {
 				delete(h.clientMap, client.Token)
-				close(client.send)
 			}
+			client.Close()
 			delete(h.clientMap, client.Token)
 		case message := <-h.broadcast:
 			for _, client := range h.clientMap {
@@ -59,3 +65,5 @@ func (h *Hub) run() {
 		}
 	}
 }
+
+var AgentHub = newHub()

+ 3 - 14
server/modules/agent_server/server.go

@@ -13,16 +13,11 @@ var upgrader = websocket.Upgrader{
 }
 
 type Server struct {
-	hub *Hub
 }
 
-var AgentHub = newHub()
-
 func NewServer() *Server {
 	go AgentHub.run()
-	return &Server{
-		hub: AgentHub,
-	}
+	return &Server{}
 }
 
 func SetMessageHandler(key string, handler func(c *WsClient, message *models.AgentData) (interface{}, error)) {
@@ -36,13 +31,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	token := r.Header.Get("Token")
-	client := &WsClient{
-		Token:     token,
-		hub:       s.hub,
-		conn:      conn,
-		send:      make(chan []byte, 1024),
-		callbacks: make(map[string]chan *models.AgentData),
-	}
-	client.hub.register <- client
+	client := NewWsClient(token, conn)
+	AgentHub.register <- client
 	go client.readPump()
 }

+ 18 - 4
server/modules/agent_server/ws_client.go

@@ -6,6 +6,7 @@ import (
 	"github.com/hashicorp/go-uuid"
 	"log"
 	"nginx-ui/server/models"
+	"sync"
 	"time"
 )
 
@@ -27,12 +28,21 @@ const (
 // WsClient is a middleman between the websocket connection and the hub.
 type WsClient struct {
 	Token string `json:"id"`
-	hub   *Hub
 	// The websocket connection.
 	conn *websocket.Conn
 	// Buffered channel of outbound messages.
 	send      chan []byte
 	callbacks map[string]chan *models.AgentData
+	lock      *sync.Mutex
+}
+
+func NewWsClient(token string, conn *websocket.Conn) *WsClient {
+	return &WsClient{
+		Token:     token,
+		conn:      conn,
+		send:      make(chan []byte, 4096),
+		callbacks: make(map[string]chan *models.AgentData),
+	}
 }
 
 // readPump pumps messages from the websocket connection to the hub.
@@ -42,8 +52,7 @@ type WsClient struct {
 // reads from this goroutine.
 func (c *WsClient) readPump() {
 	defer func() {
-		c.hub.unregister <- c
-		_ = c.conn.Close()
+		AgentHub.unregister <- c
 		log.Printf("readPump closed")
 	}()
 	c.conn.SetReadLimit(maxMessageSize)
@@ -72,7 +81,7 @@ func (c *WsClient) readPump() {
 			r <- agentData
 			continue
 		}
-		handler, ok := c.hub.handlers[agentData.Type]
+		handler, ok := AgentHub.handlers[agentData.Type]
 		if ok {
 			go func() {
 				result, err := handler(c, agentData)
@@ -141,3 +150,8 @@ func (c *WsClient) JustSend(message interface{}) {
 	err := c.conn.WriteJSON(message)
 	log.Printf("JustSend: message: %s, %v", message, err)
 }
+
+func (c *WsClient) Close() {
+	_ = c.conn.Close()
+	close(c.send)
+}

+ 1 - 1
server/nginx/agent.go

@@ -60,7 +60,7 @@ func (n *AgentInstance) Run(cmd string) (string, error) {
 	if !res.Success {
 		return "", errors.New(res.Msg)
 	}
-	n.LastResult = res.Msg
+	n.LastResult = res.ReadStringData()
 	logger.Printf("out: %v", n.LastResult)
 	return n.LastResult, err
 }

+ 1 - 1
server/nginx/instance.go

@@ -193,7 +193,7 @@ func (n *Instance) Status() (bool, string) {
 	out, err := n.Run(cmd)
 	if err != nil {
 		logs.Warn("Status", err)
-		return false, out
+		return false, err.Error()
 	}
 	logs.Info("Status", out)
 	return true, out

部分文件因文件數量過多而無法顯示