mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 14:56:38 +08:00
[优化] 添加 Relay frps 连接和代理计数字段,重构相关逻辑以支持监控和可观测性
This commit is contained in:
@@ -26,6 +26,7 @@ func RelayHeartbeat(c *gin.Context) {
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
authNode, ok := c.Get("relay_node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作")
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// v20 records low-frequency Relay frps counters on nodes so the management UI
|
||||
// can show whether the relay runtime is alive and reporting tunnel load.
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type nodeV20 struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
RelayFrpsConnections int `gorm:"column:relay_frps_connections"`
|
||||
RelayFrpsProxyCount int `gorm:"column:relay_frps_proxy_count"`
|
||||
}
|
||||
|
||||
func (nodeV20) TableName() string {
|
||||
return "nodes"
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(V20())
|
||||
}
|
||||
|
||||
func V20() Migration {
|
||||
return Migration{
|
||||
FromVersion: 19,
|
||||
ToVersion: 20,
|
||||
Migrate: migrateV20,
|
||||
Validate: validateV20,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV20(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateV20(ctx, db, backend)
|
||||
}
|
||||
|
||||
func validateV20(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 19); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasColumn(&nodeV20{}, "relay_frps_connections") {
|
||||
return fmt.Errorf("column nodes.relay_frps_connections is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&nodeV20{}, "relay_frps_proxy_count") {
|
||||
return fmt.Errorf("column nodes.relay_frps_proxy_count is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -84,6 +84,8 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
|
||||
return validateDatabaseSchemaV18(db, backend)
|
||||
case 19:
|
||||
return validateDatabaseSchemaV19(db, backend)
|
||||
case 20:
|
||||
return validateDatabaseSchemaV20(db, backend)
|
||||
default:
|
||||
return fmt.Errorf("database schema validation for v%d is not defined", version)
|
||||
}
|
||||
@@ -1225,6 +1227,19 @@ func validateDatabaseSchemaV19(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV20(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV19(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "relay_frps_connections") {
|
||||
return fmt.Errorf("column nodes.relay_frps_connections is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "relay_frps_proxy_count") {
|
||||
return fmt.Errorf("column nodes.relay_frps_proxy_count is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
ctx := databaseSchemaMigrationContext{}
|
||||
migrations := []databaseSchemaMigration{}
|
||||
|
||||
@@ -40,6 +40,8 @@ type Node struct {
|
||||
RelayStatus string `json:"relay_status" gorm:"size:16;not null;default:'unknown'"`
|
||||
RelayFrpVersion string `json:"relay_frp_version" gorm:"size:64"`
|
||||
RelayVersion string `json:"relay_version" gorm:"size:64"`
|
||||
RelayFrpsConnections int `json:"relay_frps_connections" gorm:"not null;default:0"`
|
||||
RelayFrpsProxyCount int `json:"relay_frps_proxy_count" gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
func ListNodes() (nodes []*Node, err error) {
|
||||
|
||||
@@ -172,6 +172,8 @@ type NodeView struct {
|
||||
RelayStatus string `json:"relay_status"`
|
||||
RelayFrpVersion string `json:"relay_frp_version"`
|
||||
RelayVersion string `json:"relay_version"`
|
||||
RelayFrpsConnections int `json:"relay_frps_connections"`
|
||||
RelayFrpsProxyCount int `json:"relay_frps_proxy_count"`
|
||||
}
|
||||
|
||||
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) {
|
||||
|
||||
@@ -355,6 +355,8 @@ func buildNodeView(node *model.Node) *NodeView {
|
||||
view.RelayStatus = node.RelayStatus
|
||||
view.RelayFrpVersion = node.RelayFrpVersion
|
||||
view.RelayVersion = node.RelayVersion
|
||||
view.RelayFrpsConnections = node.RelayFrpsConnections
|
||||
view.RelayFrpsProxyCount = node.RelayFrpsProxyCount
|
||||
return view
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type NodeObservabilityView struct {
|
||||
HealthEvents []*model.NodeHealthEvent `json:"health_events"`
|
||||
Analytics NodeObservabilityAnalytics `json:"analytics"`
|
||||
Trends NodeObservabilityTrends `json:"trends"`
|
||||
RelayDashboard *RelayDashboardSnapshot `json:"relay_dashboard,omitempty"`
|
||||
}
|
||||
|
||||
type NodeObservabilityAnalytics struct {
|
||||
@@ -47,6 +48,25 @@ type NodeHealthEventCleanupResult struct {
|
||||
DeletedCount int64 `json:"deleted_count"`
|
||||
}
|
||||
|
||||
type RelayDashboardSnapshot struct {
|
||||
TotalProxies int `json:"total_proxies"`
|
||||
OnlineProxies int `json:"online_proxies"`
|
||||
OfflineProxies int `json:"offline_proxies"`
|
||||
Proxies []RelayProxyStat `json:"proxies"`
|
||||
TotalConnections int `json:"total_connections"`
|
||||
ClientCounts int `json:"client_counts"`
|
||||
}
|
||||
|
||||
type RelayProxyStat struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
LastStartTime string `json:"last_start_time"`
|
||||
LastCloseTime string `json:"last_close_time"`
|
||||
ClientAddr string `json:"client_addr"`
|
||||
}
|
||||
|
||||
func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabilityView, error) {
|
||||
now := time.Now()
|
||||
node, err := model.GetNodeByID(id)
|
||||
@@ -90,7 +110,7 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NodeObservabilityView{
|
||||
view := &NodeObservabilityView{
|
||||
NodeID: node.NodeID,
|
||||
Profile: profile,
|
||||
MetricSnapshots: snapshots,
|
||||
@@ -107,7 +127,40 @@ func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabi
|
||||
Network24h: buildNetworkTrendPoints(now, trendSnapshots),
|
||||
DiskIO24h: buildDiskIOTrendPoints(now, trendSnapshots),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
if node.NodeType == "tunnel_relay" {
|
||||
view.RelayDashboard = buildRelayDashboardSnapshot(node)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func buildRelayDashboardSnapshot(node *model.Node) *RelayDashboardSnapshot {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
totalProxies := node.RelayFrpsProxyCount
|
||||
if totalProxies < 0 {
|
||||
totalProxies = 0
|
||||
}
|
||||
onlineProxies := totalProxies
|
||||
if node.RelayStatus != "healthy" {
|
||||
onlineProxies = 0
|
||||
}
|
||||
return &RelayDashboardSnapshot{
|
||||
TotalProxies: totalProxies,
|
||||
OnlineProxies: onlineProxies,
|
||||
OfflineProxies: totalProxies - onlineProxies,
|
||||
Proxies: []RelayProxyStat{},
|
||||
TotalConnections: maxInt(node.RelayFrpsConnections, 0),
|
||||
ClientCounts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func CleanupNodeHealthEvents(id uint) (*NodeHealthEventCleanupResult, error) {
|
||||
|
||||
@@ -11,11 +11,16 @@ import (
|
||||
|
||||
// RelayHeartbeatPayload is the payload sent by OpenFlareRelay in each heartbeat.
|
||||
type RelayHeartbeatPayload struct {
|
||||
RelayVersion string `json:"relay_version"`
|
||||
FrpVersion string `json:"frp_version"`
|
||||
RelayStatus string `json:"relay_status"`
|
||||
FrpsConnCount int `json:"frps_connections"`
|
||||
FrpsProxyCount int `json:"frps_proxy_count"`
|
||||
RelayVersion string `json:"relay_version"`
|
||||
FrpVersion string `json:"frp_version"`
|
||||
RelayStatus string `json:"relay_status"`
|
||||
FrpsConnCount int `json:"frps_connections"`
|
||||
FrpsProxyCount int `json:"frps_proxy_count"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Profile *AgentNodeSystemProfile `json:"profile,omitempty"`
|
||||
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
|
||||
HealthEvents []AgentNodeHealthEvent `json:"health_events,omitempty"`
|
||||
}
|
||||
|
||||
// RelayConfig is the frps configuration sent to the Relay.
|
||||
@@ -48,6 +53,8 @@ func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHear
|
||||
payload.RelayVersion = strings.TrimSpace(payload.RelayVersion)
|
||||
payload.FrpVersion = strings.TrimSpace(payload.FrpVersion)
|
||||
payload.RelayStatus = normalizeRelayStatus(payload.RelayStatus)
|
||||
payload.Name = strings.TrimSpace(payload.Name)
|
||||
payload.IP = strings.TrimSpace(payload.IP)
|
||||
|
||||
changes := make(map[string]any)
|
||||
appendRelayChange := func(key string, before any, after any) {
|
||||
@@ -59,6 +66,22 @@ func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHear
|
||||
appendRelayChange("relay_version", node.RelayVersion, payload.RelayVersion)
|
||||
appendRelayChange("relay_frp_version", node.RelayFrpVersion, payload.FrpVersion)
|
||||
appendRelayChange("relay_status", node.RelayStatus, payload.RelayStatus)
|
||||
appendRelayChange("relay_frps_connections", node.RelayFrpsConnections, payload.FrpsConnCount)
|
||||
appendRelayChange("relay_frps_proxy_count", node.RelayFrpsProxyCount, payload.FrpsProxyCount)
|
||||
if payload.Name != "" && strings.TrimSpace(node.Name) == "" {
|
||||
appendRelayChange("name", node.Name, payload.Name)
|
||||
node.Name = payload.Name
|
||||
}
|
||||
if payload.IP != "" && !node.IPManualOverride {
|
||||
appendRelayChange("ip", node.IP, payload.IP)
|
||||
node.IP = payload.IP
|
||||
if !node.GeoManualOverride {
|
||||
applyGeoInfoFromIP(node, node.IP)
|
||||
changes["geo_name"] = node.GeoName
|
||||
changes["geo_latitude"] = node.GeoLatitude
|
||||
changes["geo_longitude"] = node.GeoLongitude
|
||||
}
|
||||
}
|
||||
if !node.LastSeenAt.Equal(now) {
|
||||
changes["last_seen_at"] = now
|
||||
}
|
||||
@@ -67,6 +90,8 @@ func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHear
|
||||
node.RelayVersion = payload.RelayVersion
|
||||
node.RelayFrpVersion = payload.FrpVersion
|
||||
node.RelayStatus = payload.RelayStatus
|
||||
node.RelayFrpsConnections = payload.FrpsConnCount
|
||||
node.RelayFrpsProxyCount = payload.FrpsProxyCount
|
||||
node.LastSeenAt = now
|
||||
node.Status = NodeStatusOnline
|
||||
|
||||
@@ -76,6 +101,7 @@ func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHear
|
||||
}
|
||||
}
|
||||
refreshAgentTokenCache(node)
|
||||
persistRelayHeartbeatObservability(node.NodeID, payload, node.LastSeenAt)
|
||||
|
||||
return &RelayHeartbeatResponse{
|
||||
RelayConfig: buildRelayConfig(node),
|
||||
@@ -83,6 +109,14 @@ func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHear
|
||||
}, nil
|
||||
}
|
||||
|
||||
func persistRelayHeartbeatObservability(nodeID string, payload RelayHeartbeatPayload, reportedAt time.Time) {
|
||||
persistHeartbeatObservability(nodeID, AgentNodePayload{
|
||||
Profile: payload.Profile,
|
||||
Snapshot: payload.Snapshot,
|
||||
HealthEvents: payload.HealthEvents,
|
||||
}, reportedAt)
|
||||
}
|
||||
|
||||
func buildRelayConfig(node *model.Node) *RelayConfig {
|
||||
if node == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"openflare/model"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHeartbeatRelayPersistsRuntimeAndObservability(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "node-relay-observe",
|
||||
Name: "relay-1",
|
||||
IP: "",
|
||||
AgentToken: "relay-token",
|
||||
Status: NodeStatusPending,
|
||||
NodeType: "tunnel_relay",
|
||||
RelayStatus: "unknown",
|
||||
RelayVersion: "",
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed relay node: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err := HeartbeatRelay(node, RelayHeartbeatPayload{
|
||||
RelayVersion: "v0.1.0",
|
||||
FrpVersion: "0.61.0",
|
||||
RelayStatus: "healthy",
|
||||
FrpsConnCount: 7,
|
||||
FrpsProxyCount: 3,
|
||||
Name: "relay-runtime",
|
||||
IP: "203.0.113.9",
|
||||
Profile: &AgentNodeSystemProfile{
|
||||
Hostname: "relay-runtime",
|
||||
OSName: "Ubuntu",
|
||||
OSVersion: "24.04",
|
||||
Architecture: "amd64",
|
||||
CPUCores: 4,
|
||||
ReportedAtUnix: now.Unix(),
|
||||
},
|
||||
Snapshot: &AgentNodeMetricSnapshot{
|
||||
CapturedAtUnix: now.Unix(),
|
||||
CPUUsagePercent: 12.5,
|
||||
NetworkRxBytes: 1024,
|
||||
NetworkTxBytes: 2048,
|
||||
},
|
||||
HealthEvents: []AgentNodeHealthEvent{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HeartbeatRelay failed: %v", err)
|
||||
}
|
||||
|
||||
updated, err := model.GetNodeByNodeID(node.NodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload node: %v", err)
|
||||
}
|
||||
if updated.Status != NodeStatusOnline || updated.RelayStatus != "healthy" {
|
||||
t.Fatalf("unexpected relay status: %+v", updated)
|
||||
}
|
||||
if updated.IP != "203.0.113.9" {
|
||||
t.Fatalf("expected relay IP to be updated, got %q", updated.IP)
|
||||
}
|
||||
if updated.RelayVersion != "v0.1.0" || updated.RelayFrpVersion != "0.61.0" {
|
||||
t.Fatalf("expected relay versions to be updated, got relay=%q frp=%q", updated.RelayVersion, updated.RelayFrpVersion)
|
||||
}
|
||||
if updated.RelayFrpsConnections != 7 || updated.RelayFrpsProxyCount != 3 {
|
||||
t.Fatalf("expected relay counters to be stored, got connections=%d proxies=%d", updated.RelayFrpsConnections, updated.RelayFrpsProxyCount)
|
||||
}
|
||||
|
||||
profile, err := model.GetNodeSystemProfile(node.NodeID)
|
||||
if err != nil {
|
||||
t.Fatalf("expected relay system profile: %v", err)
|
||||
}
|
||||
if profile.Hostname != "relay-runtime" || profile.OSName != "Ubuntu" {
|
||||
t.Fatalf("unexpected relay profile: %+v", profile)
|
||||
}
|
||||
|
||||
snapshots, err := model.ListNodeMetricSnapshots(node.NodeID, now.Add(-time.Minute), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list relay snapshots: %v", err)
|
||||
}
|
||||
if len(snapshots) != 1 || snapshots[0].CPUUsagePercent != 12.5 {
|
||||
t.Fatalf("unexpected relay snapshots: %+v", snapshots)
|
||||
}
|
||||
|
||||
observability, err := GetNodeObservability(updated.ID, NodeObservabilityQuery{Hours: 1, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodeObservability failed: %v", err)
|
||||
}
|
||||
if observability.RelayDashboard == nil {
|
||||
t.Fatal("expected relay dashboard snapshot")
|
||||
}
|
||||
if observability.RelayDashboard.TotalConnections != 7 || observability.RelayDashboard.TotalProxies != 3 {
|
||||
t.Fatalf("unexpected relay dashboard: %+v", observability.RelayDashboard)
|
||||
}
|
||||
}
|
||||
@@ -1252,7 +1252,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<div className="grid gap-6 xl:grid-cols-1">
|
||||
<AppCard title="隧道运行状态" description="当前中继节点上活跃的代理通道。">
|
||||
{observability?.relay_dashboard?.proxies ? (
|
||||
{observability?.relay_dashboard?.proxies?.length ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-[var(--foreground-secondary)]">
|
||||
<thead className="border-b border-[var(--border-default)]">
|
||||
@@ -1507,8 +1507,17 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
|
||||
<AppCard title="版本信息">
|
||||
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>Agent:{node.agent_version || 'unknown'}</p>
|
||||
<p>Nginx:{node.nginx_version || 'unknown'}</p>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<>
|
||||
<p>Relay:{node.relay_version || 'unknown'}</p>
|
||||
<p>frps:{node.relay_frp_version || 'unknown'}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Agent:{node.agent_version || 'unknown'}</p>
|
||||
<p>Nginx:{node.nginx_version || 'unknown'}</p>
|
||||
</>
|
||||
)}
|
||||
<p>当前配置:{node.current_version || '未应用'}</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
@@ -1776,7 +1785,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--foreground-primary)]">
|
||||
OpenResty 状态消息
|
||||
状态消息
|
||||
</p>
|
||||
<p className="mt-1 break-words whitespace-pre-wrap">
|
||||
{node.openresty_message || '无'}
|
||||
|
||||
@@ -344,7 +344,9 @@ export function NodesPage() {
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-sm text-[var(--foreground-secondary)]">
|
||||
{node.agent_version || 'unknown'}
|
||||
{node.node_type === 'tunnel_relay'
|
||||
? node.relay_version || node.agent_version || 'unknown'
|
||||
: node.agent_version || 'unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface NodeItem {
|
||||
relay_client_proxy_url: string;
|
||||
relay_auth_token: string;
|
||||
relay_status: string;
|
||||
relay_frp_version: string;
|
||||
relay_version: string;
|
||||
relay_frps_connections: number;
|
||||
relay_frps_proxy_count: number;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user