diff --git a/openflare_relay/cmd/relay/main.go b/openflare_relay/cmd/relay/main.go index 6b88aac7..61d5a910 100644 --- a/openflare_relay/cmd/relay/main.go +++ b/openflare_relay/cmd/relay/main.go @@ -45,7 +45,7 @@ func main() { stateStore := state.NewStore(cfg.StatePath) _ = stateStore // In the future we may use stateStore for auth caching - frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir) + frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir, cfg.InitialAuthToken()) slog.Info("detected frps version", "version", frpsManager.GetVersion()) diff --git a/openflare_relay/internal/frps/manager.go b/openflare_relay/internal/frps/manager.go index 98348752..de1d5f5d 100644 --- a/openflare_relay/internal/frps/manager.go +++ b/openflare_relay/internal/frps/manager.go @@ -3,8 +3,10 @@ package frps import ( "bytes" "context" + "encoding/json" "fmt" "log/slog" + "net/http" "os" "os/exec" "path/filepath" @@ -19,6 +21,7 @@ type Manager struct { frpsPath string dataDir string configPath string + agentToken string mu sync.RWMutex activeConfig *service.RelayConfig @@ -34,15 +37,18 @@ type RuntimeStatus struct { LastError string Connections int ProxyCount int + ClientCount int + Proxies []service.RelayProxyStat ProcessAlive bool } -func NewManager(frpsPath string, dataDir string) *Manager { +func NewManager(frpsPath string, dataDir string, agentToken string) *Manager { return &Manager{ frpsPath: frpsPath, dataDir: dataDir, configPath: filepath.Join(dataDir, "frps.toml"), status: "unhealthy", + agentToken: agentToken, } } @@ -65,15 +71,139 @@ func (m *Manager) GetStatus() string { return m.status } +type frpsServerInfo struct { + Version string `json:"version"` + BindPort int `json:"bind_port"` + CurConns int `json:"cur_conns"` + ClientCounts int `json:"client_counts"` + ProxyTypeCount map[string]int `json:"proxy_type_count"` +} + +type frpsProxyInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + ClientAddr string `json:"client_addr"` + LastStartTime string `json:"last_start_time"` + LastCloseTime string `json:"last_close_time"` +} + +type frpsProxiesResponse struct { + Proxies []frpsProxyInfo `json:"proxies"` +} + +func (m *Manager) fetchFrpsTelemetry() (connections int, proxyCount int, clientCount int, proxies []service.RelayProxyStat, err error) { + m.mu.RLock() + activeConfig := m.activeConfig + m.mu.RUnlock() + + if activeConfig == nil { + return 0, 0, 0, nil, fmt.Errorf("activeConfig is nil") + } + + dashboardPort := activeConfig.BindPort + 500 + client := &http.Client{ + Timeout: 2 * time.Second, + } + + // 1. Fetch serverinfo + infoURL := fmt.Sprintf("http://127.0.0.1:%d/api/serverinfo", dashboardPort) + req, err := http.NewRequest(http.MethodGet, infoURL, nil) + if err != nil { + return 0, 0, 0, nil, err + } + password := m.agentToken + if password == "" { + password = "admin" + } + req.SetBasicAuth("admin", password) + resp, err := client.Do(req) + if err != nil { + return 0, 0, 0, nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, 0, 0, nil, fmt.Errorf("serverinfo returned status %d", resp.StatusCode) + } + + var info frpsServerInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return 0, 0, 0, nil, err + } + + connections = info.CurConns + clientCount = info.ClientCounts + + totalProxies := 0 + for _, count := range info.ProxyTypeCount { + totalProxies += count + } + proxyCount = totalProxies + + // 2. Fetch HTTP proxies list + proxiesURL := fmt.Sprintf("http://127.0.0.1:%d/api/proxy/http", dashboardPort) + req2, err := http.NewRequest(http.MethodGet, proxiesURL, nil) + if err != nil { + return connections, proxyCount, clientCount, nil, nil + } + req2.SetBasicAuth("admin", password) + resp2, err := client.Do(req2) + if err != nil { + return connections, proxyCount, clientCount, nil, nil + } + defer resp2.Body.Close() + + if resp2.StatusCode == http.StatusOK { + var listResp frpsProxiesResponse + if err := json.NewDecoder(resp2.Body).Decode(&listResp); err == nil { + for _, p := range listResp.Proxies { + proxies = append(proxies, service.RelayProxyStat{ + Name: p.Name, + Type: p.Type, + Status: p.Status, + ClientAddr: p.ClientAddr, + LastStartTime: p.LastStartTime, + LastCloseTime: p.LastCloseTime, + }) + } + } + } + + return connections, proxyCount, clientCount, proxies, nil +} + func (m *Manager) GetRuntimeStatus() RuntimeStatus { m.mu.RLock() - defer m.mu.RUnlock() + status := m.status + lastError := m.lastError + cmd := m.cmd + m.mu.RUnlock() + + connections := 0 + proxyCount := 0 + clientCount := 0 + var proxies []service.RelayProxyStat + + if cmd != nil && cmd.Process != nil && status == "healthy" { + if conns, pCount, cCount, pxs, err := m.fetchFrpsTelemetry(); err == nil { + connections = conns + proxyCount = pCount + clientCount = cCount + proxies = pxs + } else { + slog.Debug("failed to fetch frps telemetry", "error", err) + } + } + return RuntimeStatus{ - Status: m.status, - LastError: m.lastError, - Connections: 0, - ProxyCount: 0, - ProcessAlive: m.cmd != nil && m.cmd.Process != nil, + Status: status, + LastError: lastError, + Connections: connections, + ProxyCount: proxyCount, + ClientCount: clientCount, + Proxies: proxies, + ProcessAlive: cmd != nil && cmd.Process != nil, } } @@ -88,7 +218,8 @@ func (m *Manager) UpdateConfig(cfg *service.RelayConfig) { if m.activeConfig != nil && m.activeConfig.BindPort == cfg.BindPort && m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort && - m.activeConfig.AuthToken == cfg.AuthToken { + m.activeConfig.AuthToken == cfg.AuthToken && + m.activeConfig.WebServerEnabled == cfg.WebServerEnabled { if m.cmd == nil && !m.stopping { slog.Warn("frps config unchanged but process is not running, restarting") if err := m.restartProcess(); err != nil { @@ -136,6 +267,22 @@ func (m *Manager) renderConfig(cfg *service.RelayConfig) error { buf.WriteString(fmt.Sprintf("token = \"%s\"\n", cfg.AuthToken)) } + // WebServer configuration + buf.WriteString("\n[webServer]\n") + if cfg.WebServerEnabled { + buf.WriteString("addr = \"0.0.0.0\"\n") + } else { + buf.WriteString("addr = \"127.0.0.1\"\n") + } + buf.WriteString(fmt.Sprintf("port = %d\n", cfg.BindPort+500)) + buf.WriteString("user = \"admin\"\n") + + password := m.agentToken + if password == "" { + password = "admin" + } + buf.WriteString(fmt.Sprintf("password = \"%s\"\n", password)) + return os.WriteFile(m.configPath, buf.Bytes(), 0644) } diff --git a/openflare_relay/internal/heartbeat/service.go b/openflare_relay/internal/heartbeat/service.go index 1e4ceaf0..8b3f24d0 100644 --- a/openflare_relay/internal/heartbeat/service.go +++ b/openflare_relay/internal/heartbeat/service.go @@ -54,16 +54,18 @@ func (s *Service) doHeartbeat(ctx context.Context) { runtimeStatus := s.frpsManager.GetRuntimeStatus() payload := service.RelayHeartbeatPayload{ - Version: config.Version, - ExtVersion: s.frpsManager.GetVersion(), - RelayStatus: runtimeStatus.Status, - FrpsConnCount: runtimeStatus.Connections, - FrpsProxyCount: runtimeStatus.ProxyCount, - Name: s.config.NodeName, - IP: s.config.NodeIP, - Profile: observability.BuildProfile(s.config, s.stateStore), - Snapshot: observability.BuildSnapshot(s.config, s.stateStore), - HealthEvents: observability.BuildHealthEvents(runtimeStatus), + Version: config.Version, + ExtVersion: s.frpsManager.GetVersion(), + RelayStatus: runtimeStatus.Status, + FrpsConnCount: runtimeStatus.Connections, + FrpsProxyCount: runtimeStatus.ProxyCount, + FrpsClientCount: runtimeStatus.ClientCount, + FrpsProxies: runtimeStatus.Proxies, + Name: s.config.NodeName, + IP: s.config.NodeIP, + Profile: observability.BuildProfile(s.config, s.stateStore), + Snapshot: observability.BuildSnapshot(s.config, s.stateStore), + HealthEvents: observability.BuildHealthEvents(runtimeStatus), } resp, err := s.client.Heartbeat(ctx, payload) diff --git a/openflare_server/model/migrate/v17.go b/openflare_server/model/migrate/v17.go new file mode 100644 index 00000000..adcd114f --- /dev/null +++ b/openflare_server/model/migrate/v17.go @@ -0,0 +1,58 @@ +package migrate + +import ( + "fmt" + + "gorm.io/gorm" +) + +type nodeV17 struct{} + +func (nodeV17) TableName() string { + return "nodes" +} + +func init() { + Register(V17()) +} + +func V17() Migration { + return Migration{ + FromVersion: 16, + ToVersion: 17, + Migrate: migrateV17, + Validate: validateV17, + } +} + +func migrateV17(ctx Context, db *gorm.DB, backend string) error { + if err := ctx.ApplyCurrentSchema(db, backend); err != nil { + return err + } + return nil +} + +func validateV17(ctx Context, db *gorm.DB, backend string) error { + if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 16); err != nil { + return err + } + if db == nil { + return fmt.Errorf("database handle is nil") + } + + migrator := db.Migrator() + if !migrator.HasColumn(&nodeV17{}, "relay_web_server_enabled") { + return fmt.Errorf("column nodes.relay_web_server_enabled is missing") + } + + // Validate columns on a sharded partition table + for _, shard := range []string{"node_observation_frps_00"} { + for _, column := range []string{"frps_client_count", "frps_proxies"} { + if !migrator.HasColumn(shard, column) { + return fmt.Errorf("column %s.%s is missing", shard, column) + } + } + } + + return nil +} diff --git a/openflare_server/model/migrations.go b/openflare_server/model/migrations.go index 9d6e48eb..7ba1d151 100644 --- a/openflare_server/model/migrations.go +++ b/openflare_server/model/migrations.go @@ -86,6 +86,8 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB, return validateDatabaseSchemaV15(db, backend) case 16: return validateDatabaseSchemaV16(db, backend) + case 17: + return validateDatabaseSchemaV17(db, backend) default: return fmt.Errorf("database schema validation for v%d is not defined", version) } @@ -1301,6 +1303,31 @@ func validateDatabaseSchemaV16(db *gorm.DB, backend string) error { return nil } +func validateDatabaseSchemaV17(db *gorm.DB, backend string) error { + if err := validateDatabaseSchemaV16(db, backend); err != nil { + return err + } + if db == nil { + return fmt.Errorf("database handle is nil") + } + + migrator := db.Migrator() + if !migrator.HasColumn(&Node{}, "relay_web_server_enabled") { + return fmt.Errorf("column nodes.relay_web_server_enabled is missing") + } + + // Validate columns on a sharded partition table + for _, shard := range []string{"node_observation_frps_00"} { + for _, column := range []string{"frps_client_count", "frps_proxies"} { + if !migrator.HasColumn(shard, column) { + return fmt.Errorf("column %s.%s is missing", shard, column) + } + } + } + + return nil +} + func databaseSchemaMigrations() []databaseSchemaMigration { ctx := databaseSchemaMigrationContext{} migrations := []databaseSchemaMigration{} diff --git a/openflare_server/model/node.go b/openflare_server/model/node.go index 0853f90f..c5160e83 100644 --- a/openflare_server/model/node.go +++ b/openflare_server/model/node.go @@ -38,6 +38,7 @@ type Node struct { RelayClientAccessAddr string `json:"relay_client_access_addr" gorm:"size:255"` RelayClientProxyURL string `json:"relay_client_proxy_url" gorm:"size:512"` RelayStatus string `json:"relay_status" gorm:"size:16;not null;default:'unknown'"` + RelayWebServerEnabled bool `json:"relay_web_server_enabled" gorm:"not null;default:false"` } func ListNodes() (nodes []*Node, err error) { diff --git a/openflare_server/model/node_observation_frps.go b/openflare_server/model/node_observation_frps.go index 5e80bf0e..fb2a129b 100644 --- a/openflare_server/model/node_observation_frps.go +++ b/openflare_server/model/node_observation_frps.go @@ -13,6 +13,8 @@ type NodeObservationFrps struct { CapturedAt time.Time `json:"captured_at" gorm:"index"` FrpsConnections int `json:"frps_connections"` FrpsProxyCount int `json:"frps_proxy_count"` + FrpsClientCount int `json:"frps_client_count"` + FrpsProxies string `json:"frps_proxies" gorm:"type:text"` CreatedAt time.Time `json:"created_at"` } diff --git a/openflare_server/service/agent.go b/openflare_server/service/agent.go index 37b0716e..c2fd7ee7 100644 --- a/openflare_server/service/agent.go +++ b/openflare_server/service/agent.go @@ -171,6 +171,7 @@ type NodeView struct { RelayClientAccessAddr string `json:"relay_client_access_addr"` RelayClientProxyURL string `json:"relay_client_proxy_url"` RelayStatus string `json:"relay_status"` + RelayWebServerEnabled bool `json:"relay_web_server_enabled"` } func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) { diff --git a/openflare_server/service/node.go b/openflare_server/service/node.go index 6d14299a..53b1ff00 100644 --- a/openflare_server/service/node.go +++ b/openflare_server/service/node.go @@ -31,6 +31,7 @@ type NodeInput struct { RelayAgentAccessAddr string `json:"relay_agent_access_addr"` RelayClientAccessAddr string `json:"relay_client_access_addr"` RelayClientProxyURL string `json:"relay_client_proxy_url"` + RelayWebServerEnabled bool `json:"relay_web_server_enabled"` } type NodeAgentUpdateInput struct { @@ -100,6 +101,7 @@ func CreateNode(input NodeInput) (*NodeView, error) { node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr) node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr) node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL) + node.RelayWebServerEnabled = input.RelayWebServerEnabled } if !node.GeoManualOverride { applyGeoInfoFromIP(node, node.IP) @@ -137,6 +139,7 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) { node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr) node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr) node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL) + node.RelayWebServerEnabled = input.RelayWebServerEnabled if input.RelayBindPort > 0 { node.RelayBindPort = input.RelayBindPort } @@ -354,6 +357,7 @@ func buildNodeView(node *model.Node) *NodeView { view.RelayClientAccessAddr = node.RelayClientAccessAddr view.RelayClientProxyURL = node.RelayClientProxyURL view.RelayStatus = node.RelayStatus + view.RelayWebServerEnabled = node.RelayWebServerEnabled view.Version = node.Version view.ExtVersion = node.ExtVersion return view diff --git a/openflare_server/service/node_observability.go b/openflare_server/service/node_observability.go index a97e32dc..48e9c143 100644 --- a/openflare_server/service/node_observability.go +++ b/openflare_server/service/node_observability.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "errors" "openflare/model" "time" @@ -146,24 +147,44 @@ func buildRelayDashboardSnapshot(node *model.Node, obs *model.NodeObservationFrp } totalProxies := 0 totalConnections := 0 + clientCounts := 0 + proxies := []RelayProxyStat{} + if obs != nil { totalProxies = obs.FrpsProxyCount totalConnections = obs.FrpsConnections + clientCounts = obs.FrpsClientCount + if obs.FrpsProxies != "" { + var decoded []RelayProxyStat + if err := json.Unmarshal([]byte(obs.FrpsProxies), &decoded); err == nil { + proxies = decoded + } + } } if totalProxies < 0 { totalProxies = 0 } - onlineProxies := totalProxies - if node.RelayStatus != "healthy" { - onlineProxies = 0 + onlineProxies := 0 + for _, p := range proxies { + if p.Status == "online" { + onlineProxies++ + } } + // Fallback for backward compatibility + if len(proxies) == 0 { + onlineProxies = totalProxies + if node.RelayStatus != "healthy" { + onlineProxies = 0 + } + } + return &RelayDashboardSnapshot{ TotalProxies: totalProxies, OnlineProxies: onlineProxies, OfflineProxies: totalProxies - onlineProxies, - Proxies: []RelayProxyStat{}, + Proxies: proxies, TotalConnections: maxInt(totalConnections, 0), - ClientCounts: 0, + ClientCounts: maxInt(clientCounts, 0), } } diff --git a/openflare_server/service/relay.go b/openflare_server/service/relay.go index dcdedcc0..958bff7d 100644 --- a/openflare_server/service/relay.go +++ b/openflare_server/service/relay.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "errors" "fmt" "log/slog" @@ -16,26 +17,29 @@ import ( // RelayHeartbeatPayload is the payload sent by OpenFlareRelay in each heartbeat. type RelayHeartbeatPayload struct { - Version string `json:"version"` - ExtVersion 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"` + Version string `json:"version"` + ExtVersion string `json:"frp_version"` + RelayStatus string `json:"relay_status"` + FrpsConnCount int `json:"frps_connections"` + FrpsProxyCount int `json:"frps_proxy_count"` + FrpsClientCount int `json:"frps_client_count"` + FrpsProxies []RelayProxyStat `json:"frps_proxies,omitempty"` + Name string `json:"name"` + IP string `json:"ip"` + Profile *AgentNodeSystemProfile `json:"profile,omitempty"` + Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"` + HealthEvents []AgentNodeHealthEvent `json:"health_events,omitempty"` } const relayFrpsUnhealthyEventType = "frps_unhealthy" // RelayConfig is the frps configuration sent to the Relay. type RelayConfig struct { - BindPort int `json:"bind_port"` - VhostHTTPPort int `json:"vhost_http_port"` - AuthToken string `json:"auth_token"` - LogLevel string `json:"log_level"` + BindPort int `json:"bind_port"` + VhostHTTPPort int `json:"vhost_http_port"` + AuthToken string `json:"auth_token"` + LogLevel string `json:"log_level"` + WebServerEnabled bool `json:"web_server_enabled"` } // RelaySettings contains runtime settings for the Relay. @@ -148,11 +152,20 @@ func persistRelayHeartbeatObservability(nodeID string, payload RelayHeartbeatPay HealthEvents: payload.HealthEvents, }, reportedAt) + var proxiesJSON string + if len(payload.FrpsProxies) > 0 { + if data, err := json.Marshal(payload.FrpsProxies); err == nil { + proxiesJSON = string(data) + } + } + frpsObs := &model.NodeObservationFrps{ NodeID: nodeID, CapturedAt: reportedAt, FrpsConnections: payload.FrpsConnCount, FrpsProxyCount: payload.FrpsProxyCount, + FrpsClientCount: payload.FrpsClientCount, + FrpsProxies: proxiesJSON, } _ = frpsObs.Insert() } @@ -162,10 +175,11 @@ func buildRelayConfig(node *model.Node) *RelayConfig { return nil } return &RelayConfig{ - BindPort: node.RelayBindPort, - VhostHTTPPort: node.RelayVhostHTTPPort, - AuthToken: node.RelayAuthToken, - LogLevel: "info", + BindPort: node.RelayBindPort, + VhostHTTPPort: node.RelayVhostHTTPPort, + AuthToken: node.RelayAuthToken, + LogLevel: "info", + WebServerEnabled: node.RelayWebServerEnabled, } } diff --git a/openflare_server/web/features/nodes/components/node-editor-modal.tsx b/openflare_server/web/features/nodes/components/node-editor-modal.tsx index 2acf7373..1cad19aa 100644 --- a/openflare_server/web/features/nodes/components/node-editor-modal.tsx +++ b/openflare_server/web/features/nodes/components/node-editor-modal.tsx @@ -51,6 +51,7 @@ const nodeEditorSchema = z relay_client_access_addr: z.string().trim(), relay_agent_access_addr: z.string().trim(), relay_client_proxy_url: z.string().trim(), + relay_web_server_enabled: z.boolean(), geo_manual_override: z.boolean(), geo_region: z.string(), geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'), @@ -142,6 +143,7 @@ const defaultValues: NodeEditorValues = { relay_client_access_addr: '', relay_agent_access_addr: '', relay_client_proxy_url: '', + relay_web_server_enabled: false, geo_manual_override: false, geo_region: '', geo_name: '', @@ -233,6 +235,7 @@ function buildFormValues(node?: Partial | null): NodeEditorValues { relay_client_access_addr: node.relay_client_access_addr ?? '', relay_agent_access_addr: node.relay_agent_access_addr ?? '', relay_client_proxy_url: node.relay_client_proxy_url ?? '', + relay_web_server_enabled: node.relay_web_server_enabled ?? false, geo_manual_override: node.geo_manual_override ?? false, geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '', geo_name: node.geo_name ?? '', @@ -259,6 +262,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload { relay_client_access_addr: values.type === 'tunnel_relay' ? values.relay_client_access_addr.trim() : undefined, relay_agent_access_addr: values.type === 'tunnel_relay' ? values.relay_agent_access_addr.trim() : undefined, relay_client_proxy_url: values.type === 'tunnel_relay' ? values.relay_client_proxy_url.trim() : undefined, + relay_web_server_enabled: values.type === 'tunnel_relay' ? values.relay_web_server_enabled : undefined, }; if (!values.geo_manual_override) { @@ -426,6 +430,18 @@ export function NodeEditorModal({ > + + + form.setValue('relay_web_server_enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> )} diff --git a/openflare_server/web/features/nodes/components/relay-detail-page.tsx b/openflare_server/web/features/nodes/components/relay-detail-page.tsx index 5e5c8591..c573f6ad 100644 --- a/openflare_server/web/features/nodes/components/relay-detail-page.tsx +++ b/openflare_server/web/features/nodes/components/relay-detail-page.tsx @@ -866,6 +866,21 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {

Relay 中继版本:{node.version || 'unknown'}

frps 核心版本:{node.ext_version || 'unknown'}

中继网络接入:{node.relay_agent_access_addr || '—'}

+ {node.relay_web_server_enabled ? ( +

+ FRPS WebUI: + + 点击打开 Web 界面 + +

+ ) : ( +

FRPS WebUI:已禁用

+ )} diff --git a/openflare_server/web/features/nodes/types.ts b/openflare_server/web/features/nodes/types.ts index 57fda0ac..82b0572f 100644 --- a/openflare_server/web/features/nodes/types.ts +++ b/openflare_server/web/features/nodes/types.ts @@ -14,6 +14,7 @@ export interface NodeItem { relay_client_proxy_url: string; relay_auth_token: string; relay_status: string; + relay_web_server_enabled: boolean; relay_frps_connections: number; relay_frps_proxy_count: number; geo_name: string; @@ -59,6 +60,7 @@ export interface NodeMutationPayload { relay_client_access_addr?: string; relay_agent_access_addr?: string; relay_client_proxy_url?: string; + relay_web_server_enabled?: boolean; auto_update_enabled: boolean; geo_name: string; geo_latitude?: number | null;