mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +08:00
[新增] 实现 Agent WebSocket 连接升级功能,支持状态上报和配置广播
This commit is contained in:
@@ -53,6 +53,7 @@ var NodeOfflineThreshold = 2 * time.Minute
|
||||
|
||||
// V3 operational settings (hot-reloadable via Option table)
|
||||
var AgentHeartbeatInterval = 10000 // milliseconds
|
||||
var AgentWebsocketUpgradeEnabled = true
|
||||
var AgentUpdateRepo = "Rain-kl/OpenFlare"
|
||||
var GeoIPProvider = "ipinfo"
|
||||
var DatabaseAutoCleanupEnabled = false
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// AgentRegister godoc
|
||||
@@ -122,6 +128,121 @@ func AgentReportApplyLog(c *gin.Context) {
|
||||
respondSuccess(c, log)
|
||||
}
|
||||
|
||||
// AgentWebSocket godoc
|
||||
// @Summary Upgrade agent connection to websocket
|
||||
// @Tags Agent
|
||||
// @Security AgentTokenAuth
|
||||
// @Router /api/agent/ws [get]
|
||||
func AgentWebSocket(c *gin.Context) {
|
||||
authNode, ok := c.Get("agent_node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作,Agent Token 无效")
|
||||
return
|
||||
}
|
||||
node := authNode.(*model.Node)
|
||||
slog.Debug("agent ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
|
||||
websocket.Handler(func(conn *websocket.Conn) {
|
||||
client := service.RegisterAgentWSClient(node.NodeID)
|
||||
defer service.UnregisterAgentWSClient(client)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
slog.Debug("agent ws connection closed", "node_id", node.NodeID)
|
||||
}()
|
||||
|
||||
slog.Debug("agent ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
|
||||
go streamAgentWSMessages(c, conn, client)
|
||||
|
||||
for {
|
||||
var message service.AgentWSInboundMessage
|
||||
_ = conn.SetReadDeadline(time.Now().Add(agentWSReadTimeout()))
|
||||
if err := websocket.JSON.Receive(conn, &message); err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
slog.Debug("agent ws receive timeout waiting for status or pong", "node_id", node.NodeID, "timeout", agentWSReadTimeout())
|
||||
return
|
||||
}
|
||||
slog.Debug("agent ws receive failed", "node_id", node.NodeID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("agent ws message received", "node_id", node.NodeID, "type", message.Type)
|
||||
switch message.Type {
|
||||
case service.AgentWSMessageTypeStatus:
|
||||
handleAgentWSStatus(c, node, message)
|
||||
case service.AgentWSMessageTypePing:
|
||||
if !service.SendAgentWSPong(node.NodeID) {
|
||||
slog.Debug("agent ws pong enqueue failed", "node_id", node.NodeID)
|
||||
}
|
||||
case service.AgentWSMessageTypePong:
|
||||
slog.Debug("agent ws pong received", "node_id", node.NodeID)
|
||||
default:
|
||||
slog.Debug("agent ws unsupported message type", "node_id", node.NodeID, "type", message.Type)
|
||||
}
|
||||
}
|
||||
}).ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
func agentWSReadTimeout() time.Duration {
|
||||
timeout := time.Duration(common.AgentHeartbeatInterval) * time.Millisecond * 3
|
||||
if timeout < 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.AgentWSClient) {
|
||||
pingTicker := time.NewTicker(30 * time.Second)
|
||||
defer pingTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case message := <-client.Messages():
|
||||
slog.Debug("agent ws sending message", "node_id", client.NodeID(), "type", message.Type)
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
slog.Debug("agent ws send failed", "node_id", client.NodeID(), "type", message.Type, "error", err)
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
message := service.AgentWSOutboundMessage{Type: service.AgentWSMessageTypePing}
|
||||
slog.Debug("agent ws sending ping", "node_id", client.NodeID())
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
slog.Debug("agent ws ping failed", "node_id", client.NodeID(), "error", err)
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
case <-client.Done():
|
||||
return
|
||||
case <-c.Request.Context().Done():
|
||||
client.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleAgentWSStatus(c *gin.Context, node *model.Node, message service.AgentWSInboundMessage) {
|
||||
var payload service.AgentNodePayload
|
||||
if err := json.Unmarshal(message.Payload, &payload); err != nil {
|
||||
slog.Debug("agent ws status payload decode failed", "node_id", node.NodeID, "error", err)
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
response, err := service.HeartbeatNode(node, payload)
|
||||
if err != nil {
|
||||
slog.Debug("agent ws status handling failed", "node_id", node.NodeID, "error", err)
|
||||
return
|
||||
}
|
||||
settingsSent := service.SendAgentWSSettings(node.NodeID, response.AgentSettings)
|
||||
activeConfigSent := false
|
||||
if response.ActiveConfig != nil {
|
||||
activeConfigSent = service.SendAgentWSActiveConfig(node.NodeID, response.ActiveConfig)
|
||||
}
|
||||
slog.Debug("agent ws status processed",
|
||||
"node_id", node.NodeID,
|
||||
"current_version", payload.CurrentVersion,
|
||||
"openresty_status", payload.OpenrestyStatus,
|
||||
"settings_sent", settingsSent,
|
||||
"active_config_sent", activeConfigSent,
|
||||
)
|
||||
}
|
||||
|
||||
// GetNodes godoc
|
||||
// @Summary List nodes
|
||||
// @Tags Nodes
|
||||
|
||||
@@ -92,6 +92,15 @@ func validateDatabaseCleanupOption(key string, value string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func validateAgentOption(key string, value string) error {
|
||||
switch key {
|
||||
case "AgentWebsocketUpgradeEnabled":
|
||||
return validateBooleanOption(key, strings.TrimSpace(value))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateOpenRestyOption(key string, value string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
|
||||
@@ -245,6 +254,9 @@ func validateOptionWithState(option model.Option, state map[string]string) error
|
||||
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAgentOption(option.Key, option.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -366,6 +378,13 @@ func UpdateOption(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if err = validateAgentOption(option.Key, option.Value); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
err = model.UpdateOption(option.Key, option.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -46,3 +46,15 @@ func TestValidateOpenRestyOption(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgentOption(t *testing.T) {
|
||||
if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "true"); err != nil {
|
||||
t.Fatalf("expected websocket upgrade option to accept true: %v", err)
|
||||
}
|
||||
if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "false"); err != nil {
|
||||
t.Fatalf("expected websocket upgrade option to accept false: %v", err)
|
||||
}
|
||||
if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "on"); err == nil {
|
||||
t.Fatal("expected websocket upgrade option to reject non-boolean value")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ func InitOptionMap() {
|
||||
common.OptionMap["TurnstileSecretKey"] = ""
|
||||
common.OptionMap["AgentDiscoveryToken"] = ""
|
||||
common.OptionMap["AgentHeartbeatInterval"] = strconv.Itoa(common.AgentHeartbeatInterval)
|
||||
common.OptionMap["AgentWebsocketUpgradeEnabled"] = strconv.FormatBool(common.AgentWebsocketUpgradeEnabled)
|
||||
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
|
||||
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
|
||||
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
|
||||
@@ -221,6 +222,8 @@ func updateOptionMap(key string, value string) {
|
||||
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
||||
common.AgentHeartbeatInterval = v
|
||||
}
|
||||
case "AgentWebsocketUpgradeEnabled":
|
||||
common.AgentWebsocketUpgradeEnabled = value == "true"
|
||||
case "NodeOfflineThreshold":
|
||||
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
||||
common.NodeOfflineThreshold = time.Duration(v) * time.Millisecond
|
||||
|
||||
@@ -197,6 +197,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
authorizedRoute := agentRoute.Group("/")
|
||||
authorizedRoute.Use(middleware.AgentAuth())
|
||||
{
|
||||
authorizedRoute.GET("/ws", controller.AgentWebSocket)
|
||||
authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
|
||||
authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
|
||||
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
||||
|
||||
@@ -89,13 +89,14 @@ type AgentConfigResponse struct {
|
||||
}
|
||||
|
||||
type AgentSettings struct {
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
UpdateRepo string `json:"update_repo"`
|
||||
UpdateNow bool `json:"update_now"`
|
||||
UpdateChannel string `json:"update_channel"`
|
||||
UpdateTag string `json:"update_tag"`
|
||||
RestartOpenrestyNow bool `json:"restart_openresty_now"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
WebsocketUpgradeEnabled bool `json:"websocket_upgrade_enabled"`
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
UpdateRepo string `json:"update_repo"`
|
||||
UpdateNow bool `json:"update_now"`
|
||||
UpdateChannel string `json:"update_channel"`
|
||||
UpdateTag string `json:"update_tag"`
|
||||
RestartOpenrestyNow bool `json:"restart_openresty_now"`
|
||||
}
|
||||
|
||||
type ActiveConfigMeta struct {
|
||||
@@ -130,7 +131,7 @@ type NodeView struct {
|
||||
OpenrestyMessage string `json:"openresty_message"`
|
||||
Status string `json:"status"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
LastSeenAt any `json:"last_seen_at"`
|
||||
LastError string `json:"last_error"`
|
||||
LatestApplyResult string `json:"latest_apply_result"`
|
||||
LatestApplyMessage string `json:"latest_apply_message"`
|
||||
@@ -177,20 +178,32 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
|
||||
return nil, err
|
||||
}
|
||||
return &HeartbeatResponse{
|
||||
Node: node,
|
||||
AgentSettings: &AgentSettings{
|
||||
HeartbeatInterval: common.AgentHeartbeatInterval,
|
||||
AutoUpdate: node.AutoUpdateEnabled,
|
||||
UpdateRepo: common.AgentUpdateRepo,
|
||||
UpdateNow: updateNow,
|
||||
UpdateChannel: updateChannel.String(),
|
||||
UpdateTag: updateTag,
|
||||
RestartOpenrestyNow: restartOpenrestyNow,
|
||||
},
|
||||
ActiveConfig: activeConfig,
|
||||
Node: node,
|
||||
AgentSettings: buildAgentSettings(node, updateNow, updateChannel.String(), updateTag, restartOpenrestyNow),
|
||||
ActiveConfig: activeConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildAgentSettings(node *model.Node, updateNow bool, updateChannel string, updateTag string, restartOpenrestyNow bool) *AgentSettings {
|
||||
autoUpdate := false
|
||||
if node != nil {
|
||||
autoUpdate = node.AutoUpdateEnabled
|
||||
}
|
||||
if strings.TrimSpace(updateChannel) == "" {
|
||||
updateChannel = ReleaseChannelStable.String()
|
||||
}
|
||||
return &AgentSettings{
|
||||
HeartbeatInterval: common.AgentHeartbeatInterval,
|
||||
WebsocketUpgradeEnabled: common.AgentWebsocketUpgradeEnabled,
|
||||
AutoUpdate: autoUpdate,
|
||||
UpdateRepo: common.AgentUpdateRepo,
|
||||
UpdateNow: updateNow,
|
||||
UpdateChannel: updateChannel,
|
||||
UpdateTag: strings.TrimSpace(updateTag),
|
||||
RestartOpenrestyNow: restartOpenrestyNow,
|
||||
}
|
||||
}
|
||||
|
||||
func GetActiveConfigMetaForAgent() (*ActiveConfigMeta, error) {
|
||||
version, err := model.GetActiveConfigVersion()
|
||||
if err != nil {
|
||||
@@ -438,6 +451,9 @@ func computeNodeStatus(node *model.Node) string {
|
||||
if node == nil {
|
||||
return NodeStatusOffline
|
||||
}
|
||||
if IsAgentWSConnected(node.NodeID) {
|
||||
return NodeStatusOnline
|
||||
}
|
||||
if node.LastSeenAt.IsZero() {
|
||||
return NodeStatusPending
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
AgentWSMessageTypeStatus = "status"
|
||||
AgentWSMessageTypeSettings = "settings"
|
||||
AgentWSMessageTypeActiveConfig = "active_config"
|
||||
AgentWSMessageTypePing = "ping"
|
||||
AgentWSMessageTypePong = "pong"
|
||||
|
||||
AgentWSConnectedLastSeenValue = "__OPENFLARE_WS_CONNECTED__"
|
||||
)
|
||||
|
||||
type AgentWSInboundMessage struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type AgentWSOutboundMessage struct {
|
||||
Type string `json:"type"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type AgentWSBroadcastResult struct {
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
ClientCount int `json:"client_count"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedNodes []string `json:"failed_nodes"`
|
||||
}
|
||||
|
||||
type AgentWSClient struct {
|
||||
nodeID string
|
||||
send chan AgentWSOutboundMessage
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) NodeID() string {
|
||||
if client == nil {
|
||||
return ""
|
||||
}
|
||||
return client.nodeID
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) Messages() <-chan AgentWSOutboundMessage {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
return client.send
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) Done() <-chan struct{} {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
return client.done
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) Send(message AgentWSOutboundMessage) bool {
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-client.done:
|
||||
return false
|
||||
case client.send <- message:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (client *AgentWSClient) Close() {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
client.once.Do(func() {
|
||||
close(client.done)
|
||||
})
|
||||
}
|
||||
|
||||
type agentWSHub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*AgentWSClient
|
||||
}
|
||||
|
||||
var defaultAgentWSHub = &agentWSHub{
|
||||
clients: make(map[string]*AgentWSClient),
|
||||
}
|
||||
|
||||
func RegisterAgentWSClient(nodeID string) *AgentWSClient {
|
||||
client := &AgentWSClient{
|
||||
nodeID: nodeID,
|
||||
send: make(chan AgentWSOutboundMessage, 16),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
defaultAgentWSHub.mu.Lock()
|
||||
if existing := defaultAgentWSHub.clients[nodeID]; existing != nil {
|
||||
slog.Debug("agent ws replacing existing connection", "node_id", nodeID)
|
||||
existing.Close()
|
||||
}
|
||||
defaultAgentWSHub.clients[nodeID] = client
|
||||
count := len(defaultAgentWSHub.clients)
|
||||
defaultAgentWSHub.mu.Unlock()
|
||||
slog.Debug("agent ws connection registered", "node_id", nodeID, "client_count", count)
|
||||
return client
|
||||
}
|
||||
|
||||
func UnregisterAgentWSClient(client *AgentWSClient) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
defaultAgentWSHub.mu.Lock()
|
||||
if current := defaultAgentWSHub.clients[client.nodeID]; current == client {
|
||||
delete(defaultAgentWSHub.clients, client.nodeID)
|
||||
}
|
||||
count := len(defaultAgentWSHub.clients)
|
||||
defaultAgentWSHub.mu.Unlock()
|
||||
client.Close()
|
||||
slog.Debug("agent ws connection unregistered", "node_id", client.nodeID, "client_count", count)
|
||||
}
|
||||
|
||||
func IsAgentWSConnected(nodeID string) bool {
|
||||
defaultAgentWSHub.mu.RLock()
|
||||
client := defaultAgentWSHub.clients[nodeID]
|
||||
defaultAgentWSHub.mu.RUnlock()
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-client.done:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func AgentWSClientCount() int {
|
||||
defaultAgentWSHub.mu.RLock()
|
||||
defer defaultAgentWSHub.mu.RUnlock()
|
||||
return len(defaultAgentWSHub.clients)
|
||||
}
|
||||
|
||||
func SendAgentWSSettings(nodeID string, settings *AgentSettings) bool {
|
||||
if settings == nil {
|
||||
return false
|
||||
}
|
||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
||||
Type: AgentWSMessageTypeSettings,
|
||||
Payload: settings,
|
||||
})
|
||||
}
|
||||
|
||||
func SendAgentWSActiveConfig(nodeID string, activeConfig *ActiveConfigMeta) bool {
|
||||
if activeConfig == nil {
|
||||
return false
|
||||
}
|
||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
||||
Type: AgentWSMessageTypeActiveConfig,
|
||||
Payload: activeConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func SendAgentWSPong(nodeID string) bool {
|
||||
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
|
||||
Type: AgentWSMessageTypePong,
|
||||
})
|
||||
}
|
||||
|
||||
func sendAgentWSMessage(nodeID string, message AgentWSOutboundMessage) bool {
|
||||
defaultAgentWSHub.mu.RLock()
|
||||
client := defaultAgentWSHub.clients[nodeID]
|
||||
defaultAgentWSHub.mu.RUnlock()
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
ok := client.Send(message)
|
||||
if !ok {
|
||||
slog.Debug("agent ws send queued message failed", "node_id", nodeID, "type", message.Type)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func BroadcastAgentWSActiveConfig(activeConfig *ActiveConfigMeta) AgentWSBroadcastResult {
|
||||
result := AgentWSBroadcastResult{}
|
||||
if activeConfig == nil {
|
||||
slog.Debug("agent ws broadcast skipped because active config is nil")
|
||||
return result
|
||||
}
|
||||
result.Version = activeConfig.Version
|
||||
result.Checksum = activeConfig.Checksum
|
||||
|
||||
defaultAgentWSHub.mu.RLock()
|
||||
clients := make([]*AgentWSClient, 0, len(defaultAgentWSHub.clients))
|
||||
for _, client := range defaultAgentWSHub.clients {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
defaultAgentWSHub.mu.RUnlock()
|
||||
|
||||
result.ClientCount = len(clients)
|
||||
message := AgentWSOutboundMessage{
|
||||
Type: AgentWSMessageTypeActiveConfig,
|
||||
Payload: activeConfig,
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.Send(message) {
|
||||
result.SuccessCount++
|
||||
continue
|
||||
}
|
||||
result.FailedNodes = append(result.FailedNodes, client.NodeID())
|
||||
}
|
||||
slog.Debug("agent ws broadcast active config",
|
||||
"version", result.Version,
|
||||
"checksum", result.Checksum,
|
||||
"client_count", result.ClientCount,
|
||||
"success_count", result.SuccessCount,
|
||||
"failed_nodes", result.FailedNodes,
|
||||
)
|
||||
return result
|
||||
}
|
||||
@@ -385,6 +385,10 @@ func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
BroadcastAgentWSActiveConfig(&ActiveConfigMeta{
|
||||
Version: record.Version,
|
||||
Checksum: record.Checksum,
|
||||
})
|
||||
return &ReleaseResult{
|
||||
Version: record,
|
||||
Routes: bundle.Routes,
|
||||
@@ -409,6 +413,10 @@ func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) {
|
||||
return nil, err
|
||||
}
|
||||
version.IsActive = true
|
||||
BroadcastAgentWSActiveConfig(&ActiveConfigMeta{
|
||||
Version: version.Version,
|
||||
Checksum: version.Checksum,
|
||||
})
|
||||
return version, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -48,23 +48,23 @@ type DashboardTrends struct {
|
||||
}
|
||||
|
||||
type DashboardNodeHealth struct {
|
||||
ID uint `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Name string `json:"name"`
|
||||
GeoName string `json:"geo_name"`
|
||||
GeoLatitude *float64 `json:"geo_latitude"`
|
||||
GeoLongitude *float64 `json:"geo_longitude"`
|
||||
Status string `json:"status"`
|
||||
OpenrestyStatus string `json:"openresty_status"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
ActiveEventCount int `json:"active_event_count"`
|
||||
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
MemoryUsagePercent float64 `json:"memory_usage_percent"`
|
||||
StorageUsagePercent float64 `json:"storage_usage_percent"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
UniqueVisitorCount int64 `json:"unique_visitor_count"`
|
||||
ID uint `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Name string `json:"name"`
|
||||
GeoName string `json:"geo_name"`
|
||||
GeoLatitude *float64 `json:"geo_latitude"`
|
||||
GeoLongitude *float64 `json:"geo_longitude"`
|
||||
Status string `json:"status"`
|
||||
OpenrestyStatus string `json:"openresty_status"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LastSeenAt any `json:"last_seen_at"`
|
||||
ActiveEventCount int `json:"active_event_count"`
|
||||
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
MemoryUsagePercent float64 `json:"memory_usage_percent"`
|
||||
StorageUsagePercent float64 `json:"storage_usage_percent"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
UniqueVisitorCount int64 `json:"unique_visitor_count"`
|
||||
}
|
||||
|
||||
func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
@@ -139,7 +139,7 @@ func GetDashboardOverview() (*DashboardOverviewView, error) {
|
||||
Status: computedStatus,
|
||||
OpenrestyStatus: node.OpenrestyStatus,
|
||||
CurrentVersion: node.CurrentVersion,
|
||||
LastSeenAt: node.LastSeenAt,
|
||||
LastSeenAt: nodeViewLastSeenAt(node),
|
||||
ActiveEventCount: len(nodeActiveEvents),
|
||||
}
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ func buildNodeView(node *model.Node) *NodeView {
|
||||
OpenrestyMessage: strings.TrimSpace(node.OpenrestyMessage),
|
||||
Status: status,
|
||||
CurrentVersion: node.CurrentVersion,
|
||||
LastSeenAt: node.LastSeenAt,
|
||||
LastSeenAt: nodeViewLastSeenAt(node),
|
||||
LastError: node.LastError,
|
||||
CreatedAt: node.CreatedAt,
|
||||
UpdatedAt: node.UpdatedAt,
|
||||
@@ -288,6 +288,16 @@ func buildNodeView(node *model.Node) *NodeView {
|
||||
return view
|
||||
}
|
||||
|
||||
func nodeViewLastSeenAt(node *model.Node) any {
|
||||
if node != nil && IsAgentWSConnected(node.NodeID) {
|
||||
return AgentWSConnectedLastSeenValue
|
||||
}
|
||||
if node == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return node.LastSeenAt
|
||||
}
|
||||
|
||||
func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *float64, bool, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
ip := strings.TrimSpace(input.IP)
|
||||
|
||||
@@ -615,6 +615,43 @@ func TestListNodeViewsDoesNotPersistComputedStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentWSConnectionMarksNodeViewOnline(t *testing.T) {
|
||||
node := &model.Node{
|
||||
ID: 99,
|
||||
NodeID: "node-ws-view",
|
||||
Name: "edge-ws",
|
||||
IP: "10.0.0.9",
|
||||
Status: NodeStatusOffline,
|
||||
LastSeenAt: time.Now().Add(-common.NodeOfflineThreshold - time.Minute),
|
||||
}
|
||||
client := RegisterAgentWSClient(node.NodeID)
|
||||
defer UnregisterAgentWSClient(client)
|
||||
|
||||
view := buildNodeView(node)
|
||||
if view.Status != NodeStatusOnline {
|
||||
t.Fatalf("expected websocket-connected node to be online, got %s", view.Status)
|
||||
}
|
||||
if view.LastSeenAt != AgentWSConnectedLastSeenValue {
|
||||
t.Fatalf("expected websocket special last_seen_at, got %#v", view.LastSeenAt)
|
||||
}
|
||||
|
||||
result := BroadcastAgentWSActiveConfig(&ActiveConfigMeta{
|
||||
Version: "20260529-001",
|
||||
Checksum: "checksum-ws",
|
||||
})
|
||||
if result.ClientCount != 1 || result.SuccessCount != 1 || len(result.FailedNodes) != 0 {
|
||||
t.Fatalf("unexpected broadcast result: %+v", result)
|
||||
}
|
||||
select {
|
||||
case message := <-client.Messages():
|
||||
if message.Type != AgentWSMessageTypeActiveConfig {
|
||||
t.Fatalf("unexpected websocket message type: %s", message.Type)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected websocket broadcast message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
withFakeAccessLogGeoProvider(t, &geoip.GeoInfo{
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getNodeStatusVariant,
|
||||
getOpenrestyStatusLabel,
|
||||
getOpenrestyStatusVariant,
|
||||
isWSConnectedLastSeen,
|
||||
} from '@/features/nodes/utils';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
import {
|
||||
@@ -133,7 +134,9 @@ function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||
<p>当前版本:{node.current_version || '未应用'}</p>
|
||||
<p>
|
||||
最近心跳:
|
||||
{node.last_seen_at
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? ' WS 已连接'
|
||||
: node.last_seen_at
|
||||
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: ' 暂无'}
|
||||
</p>
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
getOpenrestyStatusLabel,
|
||||
getOpenrestyStatusVariant,
|
||||
isMeaningfulTime,
|
||||
isWSConnectedLastSeen,
|
||||
} from '@/features/nodes/utils';
|
||||
|
||||
const nodesQueryKey = ['nodes'];
|
||||
@@ -350,7 +351,9 @@ export function NodesPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{isMeaningfulTime(node.last_seen_at)
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(
|
||||
node.last_seen_at,
|
||||
)} · ${formatDateTime(node.last_seen_at)}`
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { NodeItem } from '@/features/nodes/types';
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined) {
|
||||
return Boolean(value) && !String(value).startsWith('0001-01-01');
|
||||
}
|
||||
import type { NodeItem } from '@/features/nodes/types';
|
||||
|
||||
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
||||
|
||||
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
||||
return value === WS_CONNECTED_LAST_SEEN;
|
||||
}
|
||||
|
||||
export function isMeaningfulTime(value: string | null | undefined) {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
!isWSConnectedLastSeen(value) &&
|
||||
!String(value).startsWith('0001-01-01')
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeStatusVariant(status: NodeItem['status']) {
|
||||
if (status === 'online') {
|
||||
|
||||
@@ -87,6 +87,7 @@ const defaultSystemFields = {
|
||||
|
||||
const defaultOperationFields = {
|
||||
AgentHeartbeatInterval: '10000',
|
||||
AgentWebsocketUpgradeEnabled: true,
|
||||
NodeOfflineThreshold: '120000',
|
||||
AgentUpdateRepo: 'Rain-kl/OpenFlare',
|
||||
GeoIPProvider: 'ipinfo',
|
||||
@@ -379,6 +380,10 @@ export function SettingsPage() {
|
||||
|
||||
setOperationFields({
|
||||
AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '10000',
|
||||
AgentWebsocketUpgradeEnabled: toBoolean(
|
||||
optionMap.AgentWebsocketUpgradeEnabled,
|
||||
true,
|
||||
),
|
||||
NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000',
|
||||
AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/OpenFlare',
|
||||
GeoIPProvider: optionMap.GeoIPProvider ?? 'ipinfo',
|
||||
@@ -1059,6 +1064,12 @@ export function SettingsPage() {
|
||||
await saveOptionEntries(
|
||||
[
|
||||
['AgentHeartbeatInterval', String(heartbeat)],
|
||||
[
|
||||
'AgentWebsocketUpgradeEnabled',
|
||||
String(
|
||||
operationFields.AgentWebsocketUpgradeEnabled,
|
||||
),
|
||||
],
|
||||
['NodeOfflineThreshold', String(offline)],
|
||||
[
|
||||
'AgentUpdateRepo',
|
||||
@@ -1115,6 +1126,17 @@ export function SettingsPage() {
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<ToggleField
|
||||
label="开启 WS 连接升级"
|
||||
description="开启后 Agent 会在 HTTP 心跳成功后尝试升级为 WebSocket,发布配置时可立即收到同步通知。"
|
||||
checked={operationFields.AgentWebsocketUpgradeEnabled}
|
||||
onChange={(checked) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
AgentWebsocketUpgradeEnabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-default)] pt-6">
|
||||
|
||||
Reference in New Issue
Block a user