feat: add openconnect
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/engigu/baihu-panel/internal/constant"
|
||||
"github.com/engigu/baihu-panel/internal/models"
|
||||
"github.com/engigu/baihu-panel/internal/models/vo"
|
||||
"github.com/engigu/baihu-panel/internal/services"
|
||||
"github.com/engigu/baihu-panel/internal/tunnel"
|
||||
"github.com/engigu/baihu-panel/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type InterconnectController struct {
|
||||
interconnectService *services.InterconnectService
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewInterconnectController(interconnectService *services.InterconnectService) *InterconnectController {
|
||||
return &InterconnectController{
|
||||
interconnectService: interconnectService,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetNodes 获取互联节点列表
|
||||
func (ic *InterconnectController) GetNodes(c *gin.Context) {
|
||||
nodes, err := ic.interconnectService.GetNodes()
|
||||
if err != nil {
|
||||
utils.ServerError(c, "获取互联节点失败")
|
||||
return
|
||||
}
|
||||
utils.Success(c, nodes)
|
||||
}
|
||||
|
||||
// CreateNode 创建互联节点
|
||||
func (ic *InterconnectController) CreateNode(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
node, err := ic.interconnectService.CreateNode(req.Name, req.URL, req.Token, req.Remark)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "创建互联节点失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, node)
|
||||
}
|
||||
|
||||
// UpdateNode 更新互联节点
|
||||
func (ic *InterconnectController) UpdateNode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
utils.BadRequest(c, "无效的节点ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
node, err := ic.interconnectService.UpdateNode(id, req.Name, req.URL, req.Token, req.Remark)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "更新互联节点失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, node)
|
||||
}
|
||||
|
||||
// DeleteNode 删除互联节点
|
||||
func (ic *InterconnectController) DeleteNode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
utils.BadRequest(c, "无效的节点ID")
|
||||
return
|
||||
}
|
||||
|
||||
err := ic.interconnectService.DeleteNode(id)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "删除互联节点失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// GetNodeStatus 获取单个子节点的状态
|
||||
func (ic *InterconnectController) GetNodeStatus(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
node, err := ic.interconnectService.GetNodeByID(id)
|
||||
if err != nil {
|
||||
utils.NotFound(c, "节点不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 针对反向隧道节点状态检测的特判
|
||||
if strings.HasPrefix(node.URL, "tunnel://") {
|
||||
sess := tunnel.GetSession(node.ID)
|
||||
if sess == nil {
|
||||
c.JSON(200, gin.H{"code": 500, "msg": "节点离线或反向隧道未建立", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用当前 Yamux Session 的虚拟底层连接进行拨号
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return sess.Session.Open()
|
||||
},
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "http://tunnel.local/api/v1/monitor", nil)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "构建检测请求失败")
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+node.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"code": 500, "msg": "与子节点逆向连接通讯失败", "data": nil})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
c.JSON(200, gin.H{"code": 500, "msg": "子节点检测异常", "data": string(body)})
|
||||
return
|
||||
}
|
||||
|
||||
var jsonResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &jsonResp); err != nil {
|
||||
utils.ServerError(c, "解析节点检测数据失败")
|
||||
return
|
||||
}
|
||||
|
||||
if dataMap, ok := jsonResp["data"].(map[string]interface{}); ok {
|
||||
dataMap["tunnel_connected"] = true
|
||||
dataMap["tunnel_url"] = node.URL
|
||||
if hostMap, ok := dataMap["host"].(map[string]interface{}); ok {
|
||||
hostMap["tx_bytes"] = node.Metrics.TxBytes
|
||||
hostMap["rx_bytes"] = node.Metrics.RxBytes
|
||||
}
|
||||
}
|
||||
|
||||
utils.Success(c, jsonResp["data"])
|
||||
return
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(node.URL, "/") + "/api/v1/monitor"
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "构建请求失败")
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+node.Token)
|
||||
|
||||
resp, err := ic.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"code": 500, "msg": "节点离线或网络不可达", "data": nil})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
c.JSON(200, gin.H{"code": 500, "msg": "节点返回异常", "data": string(body)})
|
||||
return
|
||||
}
|
||||
|
||||
var jsonResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &jsonResp); err != nil {
|
||||
utils.ServerError(c, "解析节点响应失败")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, jsonResp["data"])
|
||||
}
|
||||
|
||||
// SyncScript 将脚本同步到指定的节点列表
|
||||
func (ic *InterconnectController) SyncScript(c *gin.Context) {
|
||||
var req struct {
|
||||
NodeIDs []string `json:"node_ids" binding:"required"`
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0)
|
||||
|
||||
for _, nodeID := range req.NodeIDs {
|
||||
node, err := ic.interconnectService.GetNodeByID(nodeID)
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
|
||||
continue
|
||||
}
|
||||
|
||||
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/scripts/save")
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
||||
continue
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"filename": req.Filename,
|
||||
"content": req.Content,
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"})
|
||||
continue
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"})
|
||||
} else {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
utils.Success(c, results)
|
||||
}
|
||||
|
||||
// SyncEnv 将环境变量同步到指定的节点列表
|
||||
func (ic *InterconnectController) SyncEnv(c *gin.Context) {
|
||||
var req struct {
|
||||
NodeIDs []string `json:"node_ids" binding:"required"`
|
||||
Envs []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Remark string `json:"remark"`
|
||||
} `json:"envs" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0)
|
||||
|
||||
for _, nodeID := range req.NodeIDs {
|
||||
node, err := ic.interconnectService.GetNodeByID(nodeID)
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
|
||||
continue
|
||||
}
|
||||
|
||||
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/env")
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
||||
continue
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, env := range req.Envs {
|
||||
payload := map[string]interface{}{
|
||||
"name": env.Name,
|
||||
"value": env.Value,
|
||||
"remark": env.Remark,
|
||||
"type": "normal",
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
successCount++
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, map[string]interface{}{
|
||||
"node_id": nodeID,
|
||||
"success": true,
|
||||
"msg": "同步完成",
|
||||
"count": successCount,
|
||||
})
|
||||
}
|
||||
|
||||
utils.Success(c, results)
|
||||
}
|
||||
|
||||
// SyncTask 将任务同步到指定的节点列表
|
||||
func (ic *InterconnectController) SyncTask(c *gin.Context) {
|
||||
var req struct {
|
||||
NodeIDs []string `json:"node_ids" binding:"required"`
|
||||
Tasks []vo.TaskVO `json:"tasks" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0)
|
||||
|
||||
for _, nodeID := range req.NodeIDs {
|
||||
node, err := ic.interconnectService.GetNodeByID(nodeID)
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "节点不存在"})
|
||||
continue
|
||||
}
|
||||
|
||||
client, apiURL, err := ic.getClientAndURL(&node, "/api/v1/tasks/bulk_save")
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "反向隧道未连接"})
|
||||
continue
|
||||
}
|
||||
|
||||
payloadBytes, _ := json.Marshal(req.Tasks)
|
||||
|
||||
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "构建请求失败"})
|
||||
continue
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+node.Token)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": false, "msg": "同步请求失败或超时"})
|
||||
} else {
|
||||
results = append(results, map[string]interface{}{"node_id": nodeID, "success": true, "msg": "同步成功"})
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
utils.Success(c, results)
|
||||
}
|
||||
|
||||
// HandleTunnel 接受子节点 WebSocket 连接请求
|
||||
func (ic *InterconnectController) HandleTunnel(c *gin.Context) {
|
||||
tunnel.HandleTunnel(c)
|
||||
}
|
||||
|
||||
// ProxyRequest 代理转发请求至目标节点
|
||||
func (ic *InterconnectController) ProxyRequest(c *gin.Context) {
|
||||
nodeID := c.Param("node_id")
|
||||
path := c.Param("path")
|
||||
if nodeID == "" {
|
||||
utils.BadRequest(c, "Node ID required")
|
||||
return
|
||||
}
|
||||
|
||||
node, err := ic.interconnectService.GetNodeByID(nodeID)
|
||||
if err != nil {
|
||||
utils.NotFound(c, "Node not found")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(node.URL, "tunnel://") {
|
||||
// 走 WebSocket 逆向隧道 (基于 Yamux 流式多路复用)
|
||||
err := tunnel.ProxyHTTP(nodeID, c, path)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "Tunnel request failed: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 走普通 HTTP 直连
|
||||
// Construct the target URL
|
||||
targetURL := strings.TrimRight(node.URL, "/") + path
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
targetURL += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(c.Request.Method, targetURL, c.Request.Body)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "Failed to create proxy request")
|
||||
return
|
||||
}
|
||||
|
||||
// Copy headers
|
||||
req.Header = c.Request.Header.Clone()
|
||||
|
||||
// If the node token exists, append it as Bearer Auth
|
||||
if node.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+node.Token)
|
||||
}
|
||||
|
||||
resp, err := ic.httpClient.Do(req)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "Failed to connect to target node: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for k, v := range resp.Header {
|
||||
for _, vv := range v {
|
||||
c.Writer.Header().Add(k, vv)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
// ReportMonitorData 接收子节点上报的监控数据
|
||||
func (ic *InterconnectController) ReportMonitorData(c *gin.Context) {
|
||||
var req models.NodeMetrics
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(401, gin.H{"error": "missing authorization"})
|
||||
return
|
||||
}
|
||||
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
|
||||
node, err := ic.interconnectService.GetNodeByToken(tokenStr)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
err = ic.interconnectService.UpdateNodeMonitorData(node.ID, req)
|
||||
if err != nil {
|
||||
utils.ServerError(c, "更新节点数据失败")
|
||||
return
|
||||
}
|
||||
utils.Success(c, gin.H{
|
||||
"tunnel_url": node.URL,
|
||||
})
|
||||
}
|
||||
|
||||
// GetChildStatus 获取本机作为子节点的连接状态
|
||||
func (ic *InterconnectController) GetChildStatus(c *gin.Context) {
|
||||
settingsSvc := services.NewSettingsService()
|
||||
parentURL := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentURL)
|
||||
parentToken := settingsSvc.Get(constant.SectionInterconnect, constant.KeyInterconnectParentToken)
|
||||
|
||||
connected := tunnel.IsTunnelConnected()
|
||||
tunnelURL := tunnel.GetLocalTunnelURL()
|
||||
|
||||
utils.Success(c, gin.H{
|
||||
"parent_url": parentURL,
|
||||
"parent_token": parentToken,
|
||||
"connected": connected,
|
||||
"tunnel_url": tunnelURL,
|
||||
"tx_bytes": tunnel.GetTxBytes(),
|
||||
"rx_bytes": tunnel.GetRxBytes(),
|
||||
})
|
||||
}
|
||||
|
||||
// getClientAndURL 辅助方法:根据节点类型决定走直连还是隧道,并返回对应的 Client 和完整 URL
|
||||
func (ic *InterconnectController) getClientAndURL(node *models.InterconnectNode, path string) (*http.Client, string, error) {
|
||||
if strings.HasPrefix(node.URL, "tunnel://") {
|
||||
sess := tunnel.GetSession(node.ID)
|
||||
if sess == nil {
|
||||
return nil, "", net.ErrClosed
|
||||
}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return sess.Session.Open()
|
||||
},
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
return client, "http://tunnel.local" + path, nil
|
||||
}
|
||||
|
||||
targetURL := strings.TrimRight(node.URL, "/") + path
|
||||
return ic.httpClient, targetURL, nil
|
||||
}
|
||||
@@ -1,34 +1,20 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/engigu/baihu-panel/internal/constant"
|
||||
"github.com/engigu/baihu-panel/internal/services"
|
||||
"github.com/engigu/baihu-panel/internal/services/tasks"
|
||||
"github.com/engigu/baihu-panel/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
)
|
||||
|
||||
type MonitorController struct {
|
||||
executorService *tasks.ExecutorService
|
||||
|
||||
// 缓存物理机状态
|
||||
hostMu sync.RWMutex
|
||||
lastUpdate time.Time
|
||||
cpuPercent float64
|
||||
vMem *mem.VirtualMemoryStat
|
||||
diskUsage *disk.UsageStat
|
||||
hostInfo *host.InfoStat
|
||||
}
|
||||
|
||||
func NewMonitorController(executorService *tasks.ExecutorService) *MonitorController {
|
||||
@@ -49,118 +35,52 @@ func (mc *MonitorController) GetSystemMonitor(c *gin.Context) {
|
||||
utils.Success(c, data)
|
||||
}
|
||||
|
||||
// MonitorWS WebSocket 获取系统监控数据
|
||||
func (mc *MonitorController) MonitorWS(c *gin.Context) {
|
||||
ws, err := monitorUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
// MonitorSSE Server-Sent Events 获取系统监控数据
|
||||
func (mc *MonitorController) MonitorSSE(c *gin.Context) {
|
||||
// 设置 SSE 响应头
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("Transfer-Encoding", "chunked")
|
||||
|
||||
// 初始发送一次数据
|
||||
if err := mc.sendMonitorData(ws); err != nil {
|
||||
if err := mc.sendMonitorDataSSE(c); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := mc.sendMonitorData(ws); err != nil {
|
||||
if err := mc.sendMonitorDataSSE(c); err != nil {
|
||||
return // 客户端断开连接或发送失败
|
||||
}
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
return // 连接已断开,立即退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MonitorController) sendMonitorData(ws *websocket.Conn) error {
|
||||
func (mc *MonitorController) sendMonitorDataSSE(c *gin.Context) error {
|
||||
data := mc.getMonitorData()
|
||||
return ws.WriteJSON(gin.H{
|
||||
// 使用 Gin 提供的 SSE 方法
|
||||
c.SSEvent("message", gin.H{
|
||||
"code": 200,
|
||||
"data": data,
|
||||
"msg": "success",
|
||||
})
|
||||
}
|
||||
|
||||
func (mc *MonitorController) updateHostMetrics() {
|
||||
mc.hostMu.Lock()
|
||||
defer mc.hostMu.Unlock()
|
||||
|
||||
// 缓存 2 秒
|
||||
if time.Since(mc.lastUpdate) < 2*time.Second && mc.vMem != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if constant.DemoMode {
|
||||
mc.updateDemoMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
cpuPercents, _ := cpu.Percent(0, false)
|
||||
if len(cpuPercents) > 0 {
|
||||
mc.cpuPercent = cpuPercents[0]
|
||||
}
|
||||
mc.vMem, _ = mem.VirtualMemory()
|
||||
mc.diskUsage, _ = disk.Usage("/")
|
||||
mc.hostInfo, _ = host.Info()
|
||||
mc.lastUpdate = time.Now()
|
||||
}
|
||||
|
||||
func (mc *MonitorController) updateDemoMetrics() {
|
||||
mc.cpuPercent = 10 + rand.Float64()*40 // 10% - 50% 的随机 CPU 波动
|
||||
|
||||
totalMem := uint64(8 * 1024 * 1024 * 1024) // 8GB 内存
|
||||
usedMem := uint64(float64(totalMem) * (0.3 + rand.Float64()*0.3)) // 30% - 60% 随机使用率
|
||||
mc.vMem = &mem.VirtualMemoryStat{
|
||||
Total: totalMem,
|
||||
Used: usedMem,
|
||||
UsedPercent: float64(usedMem) / float64(totalMem) * 100,
|
||||
}
|
||||
|
||||
totalDisk := uint64(500 * 1024 * 1024 * 1024) // 500GB 硬盘
|
||||
usedDisk := uint64(float64(totalDisk) * 0.45) // 固定 45% 使用率
|
||||
mc.diskUsage = &disk.UsageStat{
|
||||
Total: totalDisk,
|
||||
Used: usedDisk,
|
||||
UsedPercent: float64(usedDisk) / float64(totalDisk) * 100,
|
||||
}
|
||||
|
||||
mc.hostInfo = &host.InfoStat{
|
||||
Platform: "Demo Environment",
|
||||
OS: "linux",
|
||||
Uptime: uint64(time.Now().Unix() - 1700000000), // 生成一个较长且持续增加的运行时间
|
||||
}
|
||||
mc.lastUpdate = time.Now()
|
||||
c.Writer.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mc *MonitorController) getMonitorData() gin.H {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
rt := services.GetMonitorService().GetRuntimeMetrics()
|
||||
m := rt.MemStats
|
||||
|
||||
// 更新并读取缓存的物理主机指标
|
||||
mc.updateHostMetrics()
|
||||
|
||||
mc.hostMu.RLock()
|
||||
cpuPercent := mc.cpuPercent
|
||||
vMem := mc.vMem
|
||||
diskUsage := mc.diskUsage
|
||||
hostInfo := mc.hostInfo
|
||||
mc.hostMu.RUnlock()
|
||||
|
||||
// 提供默认值防空指针
|
||||
if vMem == nil {
|
||||
vMem = &mem.VirtualMemoryStat{}
|
||||
}
|
||||
if diskUsage == nil {
|
||||
diskUsage = &disk.UsageStat{}
|
||||
}
|
||||
if hostInfo == nil {
|
||||
hostInfo = &host.InfoStat{}
|
||||
}
|
||||
// 调用统一的监控服务获取物理机指标
|
||||
metrics := services.GetMonitorService().GetHostMetrics()
|
||||
|
||||
return gin.H{
|
||||
"env": gin.H{
|
||||
@@ -168,18 +88,18 @@ func (mc *MonitorController) getMonitorData() gin.H {
|
||||
"arch": runtime.GOARCH,
|
||||
"go_version": runtime.Version(),
|
||||
"num_cpu": runtime.NumCPU(),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"goroutines": rt.NumGoroutine,
|
||||
},
|
||||
"host": gin.H{
|
||||
"cpu_percent": cpuPercent,
|
||||
"mem_total": vMem.Total,
|
||||
"mem_used": vMem.Used,
|
||||
"mem_percent": vMem.UsedPercent,
|
||||
"disk_total": diskUsage.Total,
|
||||
"disk_used": diskUsage.Used,
|
||||
"disk_percent": diskUsage.UsedPercent,
|
||||
"uptime": hostInfo.Uptime,
|
||||
"platform": hostInfo.Platform + " " + hostInfo.PlatformVersion,
|
||||
"cpu_percent": metrics.CPUPercent,
|
||||
"mem_total": metrics.VMem.Total,
|
||||
"mem_used": metrics.VMem.Used,
|
||||
"mem_percent": metrics.VMem.UsedPercent,
|
||||
"disk_total": metrics.DiskUsage.Total,
|
||||
"disk_used": metrics.DiskUsage.Used,
|
||||
"disk_percent": metrics.DiskUsage.UsedPercent,
|
||||
"uptime": metrics.HostInfo.Uptime,
|
||||
"platform": metrics.HostInfo.Platform + " " + metrics.HostInfo.PlatformVersion,
|
||||
},
|
||||
"mem": gin.H{
|
||||
"alloc": m.Alloc,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/engigu/baihu-panel/internal/models/vo"
|
||||
"github.com/engigu/baihu-panel/internal/services"
|
||||
"github.com/engigu/baihu-panel/internal/services/tasks"
|
||||
"github.com/engigu/baihu-panel/internal/tunnel"
|
||||
"github.com/engigu/baihu-panel/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -134,6 +135,20 @@ func (sc *SettingsController) ChangePassword(c *gin.Context) {
|
||||
func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
|
||||
settings := sc.settingsService.GetSection(constant.SectionSite)
|
||||
|
||||
// 纠正数据库中的空值,防止因配置冲突被意外置空
|
||||
if settings[constant.KeyTitle] == "" {
|
||||
settings[constant.KeyTitle] = "白虎面板"
|
||||
sc.settingsService.Set(constant.SectionSite, constant.KeyTitle, "白虎面板")
|
||||
}
|
||||
if settings[constant.KeySubtitle] == "" {
|
||||
settings[constant.KeySubtitle] = "极致轻量、高性能的自动化任务调度平台"
|
||||
sc.settingsService.Set(constant.SectionSite, constant.KeySubtitle, "极致轻量、高性能的自动化任务调度平台")
|
||||
}
|
||||
if settings[constant.KeyIcon] == "" {
|
||||
settings[constant.KeyIcon] = constant.DefaultIcon
|
||||
sc.settingsService.Set(constant.SectionSite, constant.KeyIcon, constant.DefaultIcon)
|
||||
}
|
||||
|
||||
// 解析 JSON 格式的 OpenAPI Token
|
||||
if tokenJson, ok := settings[constant.KeyOpenapiToken]; ok && tokenJson != "" {
|
||||
var tokenConfig vo.TokenConfig
|
||||
@@ -164,11 +179,25 @@ func (sc *SettingsController) GetSiteSettings(c *gin.Context) {
|
||||
// GetPublicSiteSettings 获取公开的站点设置(无需认证)
|
||||
func (sc *SettingsController) GetPublicSiteSettings(c *gin.Context) {
|
||||
settings := sc.settingsService.GetSection(constant.SectionSite)
|
||||
|
||||
title := settings[constant.KeyTitle]
|
||||
if title == "" {
|
||||
title = "白虎面板"
|
||||
}
|
||||
subtitle := settings[constant.KeySubtitle]
|
||||
if subtitle == "" {
|
||||
subtitle = "极致轻量、高性能的自动化任务调度平台"
|
||||
}
|
||||
icon := settings[constant.KeyIcon]
|
||||
if icon == "" {
|
||||
icon = constant.DefaultIcon
|
||||
}
|
||||
|
||||
// 只返回公开信息
|
||||
utils.Success(c, gin.H{
|
||||
constant.KeyTitle: settings[constant.KeyTitle],
|
||||
constant.KeySubtitle: settings[constant.KeySubtitle],
|
||||
constant.KeyIcon: settings[constant.KeyIcon],
|
||||
constant.KeyTitle: title,
|
||||
constant.KeySubtitle: subtitle,
|
||||
constant.KeyIcon: icon,
|
||||
"demo_mode": constant.DemoMode,
|
||||
})
|
||||
}
|
||||
@@ -530,6 +559,13 @@ func (sc *SettingsController) UpdateSectionSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 当互联配置发生改变时,通知 tunnel 模块立刻应用新角色,启动或停止相关的后台协程
|
||||
if section == constant.SectionInterconnect {
|
||||
if role, ok := values[constant.KeyInterconnectRole]; ok {
|
||||
tunnel.ApplyRole(role)
|
||||
}
|
||||
}
|
||||
|
||||
utils.SuccessMsg(c, "保存成功")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/engigu/baihu-panel/internal/constant"
|
||||
"github.com/engigu/baihu-panel/internal/database"
|
||||
"github.com/engigu/baihu-panel/internal/logger"
|
||||
"github.com/engigu/baihu-panel/internal/models"
|
||||
"github.com/engigu/baihu-panel/internal/models/vo"
|
||||
@@ -227,6 +228,87 @@ func (tc *TaskController) CreateTask(c *gin.Context) {
|
||||
utils.Success(c, vo.ToTaskVO(task))
|
||||
}
|
||||
|
||||
// BulkSaveTask 批量保存/导入任务配置(用于主节点下发同步)
|
||||
// @Summary 批量保存任务
|
||||
// @Description 批量导入任务配置,如果ID或同名存在则更新,不存在则创建
|
||||
// @Tags 任务管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Router /tasks/bulk_save [post]
|
||||
func (tc *TaskController) BulkSaveTask(c *gin.Context) {
|
||||
var reqs []vo.TaskVO
|
||||
|
||||
if err := c.ShouldBindJSON(&reqs); err != nil {
|
||||
utils.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, req := range reqs {
|
||||
param := tasks.TaskParam{
|
||||
Name: req.Name,
|
||||
Remark: req.Remark,
|
||||
Command: req.Command,
|
||||
PreCommand: req.PreCommand,
|
||||
PostCommand: req.PostCommand,
|
||||
Tags: req.Tags,
|
||||
Type: req.Type,
|
||||
Config: req.Config,
|
||||
Schedule: req.Schedule,
|
||||
Timeout: req.Timeout,
|
||||
WorkDir: req.WorkDir,
|
||||
CleanConfig: req.CleanConfig,
|
||||
Envs: req.Envs,
|
||||
Languages: req.Languages,
|
||||
AgentID: req.AgentID,
|
||||
TriggerType: req.TriggerType,
|
||||
RetryCount: req.RetryCount,
|
||||
RetryInterval: req.RetryInterval,
|
||||
RandomRange: req.RandomRange,
|
||||
PinType: req.PinType,
|
||||
Enabled: req.Enabled,
|
||||
SourceID: "", // 不直接覆盖
|
||||
}
|
||||
|
||||
var existingTask *models.Task
|
||||
// 优先按 ID 匹配
|
||||
if req.ID != "" {
|
||||
existingTask = tc.taskService.GetTaskByID(req.ID)
|
||||
}
|
||||
// 如果 ID 没找到,尝试按 Name 匹配
|
||||
if existingTask == nil {
|
||||
var t models.Task
|
||||
res := database.DB.Where("name = ?", req.Name).First(&t)
|
||||
if res.Error == nil {
|
||||
existingTask = &t
|
||||
}
|
||||
}
|
||||
|
||||
var savedTask *models.Task
|
||||
if existingTask != nil {
|
||||
savedTask = tc.taskService.UpdateTask(existingTask.ID, ¶m)
|
||||
} else {
|
||||
savedTask = tc.taskService.CreateTask(¶m)
|
||||
// 如果原始有 ID,强制覆盖更新 ID 保持强同步一致性
|
||||
if req.ID != "" && savedTask != nil {
|
||||
database.DB.Model(savedTask).Update("id", req.ID)
|
||||
savedTask.ID = req.ID
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是 Agent 任务,通知 Agent;否则添加到本地 cron
|
||||
if savedTask != nil {
|
||||
if savedTask.AgentID != nil && *savedTask.AgentID != "" {
|
||||
tc.agentWSManager.BroadcastTasks(*savedTask.AgentID)
|
||||
} else {
|
||||
tc.executorService.AddCronTask(savedTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// GetTasks 获取任务列表
|
||||
// @Summary 获取任务列表
|
||||
// @Description 分页获取任务列表,支持按名称、Agent ID、标签、类型筛选
|
||||
|
||||
@@ -224,6 +224,7 @@ func (tc *TerminalController) handlePtyMode(conn *websocket.Conn, userID string)
|
||||
close(pingDone)
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
ptmx.Close() // Force close PTY to interrupt the blocking ptmx.Read() in the goroutine
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -369,9 +370,19 @@ func (tc *TerminalController) handlePipeMode(conn *websocket.Conn, userID string
|
||||
}
|
||||
|
||||
close(pingDone)
|
||||
stdin.Close()
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
|
||||
if stdinCloser, ok := stdin.(io.Closer); ok {
|
||||
stdinCloser.Close()
|
||||
}
|
||||
if stdoutCloser, ok := stdout.(io.Closer); ok {
|
||||
stdoutCloser.Close()
|
||||
}
|
||||
if stderrCloser, ok := stderr.(io.Closer); ok {
|
||||
stderrCloser.Close()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user