[新增] 实现 Agent WebSocket 连接升级功能,支持状态上报和配置广播

This commit is contained in:
ryan
2026-05-29 09:52:34 +08:00
parent 4257b6fd5a
commit 79ed8fd6ab
29 changed files with 1128 additions and 95 deletions
+1
View File
@@ -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
+121
View File
@@ -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
+19
View File
@@ -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")
}
}
+3
View File
@@ -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
+1
View File
@@ -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)
+35 -19
View File
@@ -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
}
+226
View File
@@ -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
}
+18 -18
View File
@@ -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),
}
+11 -1
View File
@@ -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)}`
+15 -5
View File
@@ -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">