feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl
This commit is contained in:
+112
-29
@@ -25,12 +25,15 @@ var (
|
||||
onlineMapPool = sync.Pool{New: func() interface{} { return make(map[string]int) }}
|
||||
)
|
||||
|
||||
// Client communicates with the Xboard panel API
|
||||
// Client communicates with the Xboard panel API.
|
||||
// When machineID > 0 the client uses machine-level authentication
|
||||
// (machine_id + token) instead of the legacy (token + node_type) scheme.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
nodeID int
|
||||
nodeType string
|
||||
machineID int
|
||||
httpClient *http.Client
|
||||
|
||||
configETag string
|
||||
@@ -40,13 +43,14 @@ type Client struct {
|
||||
apiFailure atomic.Uint64
|
||||
}
|
||||
|
||||
// NewClient creates a new panel API client
|
||||
// NewClient creates a new panel API client.
|
||||
func NewClient(cfg config.PanelConfig) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.URL, "/"),
|
||||
token: cfg.Token,
|
||||
nodeID: cfg.NodeID,
|
||||
nodeType: cfg.NodeType,
|
||||
baseURL: strings.TrimRight(cfg.URL, "/"),
|
||||
token: cfg.Token,
|
||||
nodeID: cfg.NodeID,
|
||||
nodeType: cfg.NodeType,
|
||||
machineID: cfg.MachineID,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
@@ -58,6 +62,19 @@ func NewClient(cfg config.PanelConfig) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ForNode returns a new client bound to a specific node_id, sharing the
|
||||
// same base URL, auth and HTTP transport. ETags and metric counters start fresh.
|
||||
func (c *Client) ForNode(nodeID int) *Client {
|
||||
return &Client{
|
||||
baseURL: c.baseURL,
|
||||
token: c.token,
|
||||
nodeID: nodeID,
|
||||
nodeType: c.nodeType,
|
||||
machineID: c.machineID,
|
||||
httpClient: c.httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Handshake calls the new v2 API to get WS config + initial data in one shot.
|
||||
func (c *Client) Handshake() (*HandshakeResponse, error) {
|
||||
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
|
||||
@@ -174,9 +191,26 @@ func decodeWeakRaw(input map[string]interface{}, output interface{}) error {
|
||||
return decoder.Decode(input)
|
||||
}
|
||||
|
||||
// configPath returns the API path for config fetching.
|
||||
// Machine mode uses the v2 endpoint; legacy mode uses the v1 UniProxy endpoint.
|
||||
func (c *Client) configPath() string {
|
||||
if c.machineID > 0 {
|
||||
return "/api/v2/server/config"
|
||||
}
|
||||
return "/api/v1/server/UniProxy/config"
|
||||
}
|
||||
|
||||
// userPath returns the API path for user fetching.
|
||||
func (c *Client) userPath() string {
|
||||
if c.machineID > 0 {
|
||||
return "/api/v2/server/user"
|
||||
}
|
||||
return "/api/v1/server/UniProxy/user"
|
||||
}
|
||||
|
||||
// GetConfig fetches node configuration. Returns nil if not modified (304).
|
||||
func (c *Client) GetConfig() (*NodeConfig, error) {
|
||||
resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/config", nil, c.configETag)
|
||||
resp, err := c.doRequest("GET", c.configPath(), nil, c.configETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config: %w", err)
|
||||
}
|
||||
@@ -214,7 +248,7 @@ func (c *Client) GetConfig() (*NodeConfig, error) {
|
||||
|
||||
// GetUsers fetches available users. Returns nil if not modified (304).
|
||||
func (c *Client) GetUsers() ([]User, error) {
|
||||
resp, err := c.doRequest("GET", "/api/v1/server/UniProxy/user", nil, c.userETag)
|
||||
resp, err := c.doRequest("GET", c.userPath(), nil, c.userETag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get users: %w", err)
|
||||
}
|
||||
@@ -280,14 +314,74 @@ func (c *Client) ResetETags() {
|
||||
c.userETag = ""
|
||||
}
|
||||
|
||||
// postJSON marshals a map payload (with auth fields injected) and POSTs it.
|
||||
// Auth fields are injected before marshalling — single marshal pass.
|
||||
func (c *Client) postJSON(path string, payload map[string]interface{}) error {
|
||||
payload["token"] = c.token
|
||||
payload["node_id"] = c.nodeID
|
||||
if c.nodeType != "" {
|
||||
payload["node_type"] = c.nodeType
|
||||
// GetMachineNodes fetches the list of active nodes bound to this machine.
|
||||
func (c *Client) GetMachineNodes() (*MachineNodesResponse, error) {
|
||||
resp, err := c.doRequest("GET", "/api/v2/server/machine/nodes", nil, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get machine nodes: %w", err)
|
||||
}
|
||||
defer drainAndClose(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("machine nodes status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var out MachineNodesResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode machine nodes: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ReportMachineStatus sends machine-level load metrics to the panel.
|
||||
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64) error {
|
||||
payload := map[string]interface{}{
|
||||
"cpu": cpu,
|
||||
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
|
||||
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
|
||||
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
|
||||
}
|
||||
return c.postJSON("/api/v2/server/machine/status", payload)
|
||||
}
|
||||
|
||||
// injectAuth writes authentication fields into a payload map.
|
||||
func (c *Client) injectAuth(m map[string]interface{}) {
|
||||
m["token"] = c.token
|
||||
if c.machineID > 0 {
|
||||
m["machine_id"] = c.machineID
|
||||
if c.nodeID > 0 {
|
||||
m["node_id"] = c.nodeID
|
||||
}
|
||||
} else {
|
||||
m["node_id"] = c.nodeID
|
||||
if c.nodeType != "" {
|
||||
m["node_type"] = c.nodeType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// authQuery builds URL query parameters for GET requests.
|
||||
func (c *Client) authQuery() url.Values {
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
if c.machineID > 0 {
|
||||
q.Set("machine_id", strconv.Itoa(c.machineID))
|
||||
if c.nodeID > 0 {
|
||||
q.Set("node_id", strconv.Itoa(c.nodeID))
|
||||
}
|
||||
} else {
|
||||
q.Set("node_id", strconv.Itoa(c.nodeID))
|
||||
if c.nodeType != "" {
|
||||
q.Set("node_type", c.nodeType)
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// postJSON marshals a map payload (with auth fields injected) and POSTs it.
|
||||
func (c *Client) postJSON(path string, payload map[string]interface{}) error {
|
||||
c.injectAuth(payload)
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -313,23 +407,12 @@ func (c *Client) doRequest(method, path string, body []byte, ifNoneMatch string)
|
||||
|
||||
var bodyReader io.Reader
|
||||
if method == "GET" {
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
q.Set("node_id", strconv.Itoa(c.nodeID))
|
||||
if c.nodeType != "" {
|
||||
q.Set("node_type", c.nodeType)
|
||||
}
|
||||
fullURL += "?" + q.Encode()
|
||||
fullURL += "?" + c.authQuery().Encode()
|
||||
} else if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
} else {
|
||||
authOnly := map[string]interface{}{
|
||||
"token": c.token,
|
||||
"node_id": c.nodeID,
|
||||
}
|
||||
if c.nodeType != "" {
|
||||
authOnly["node_type"] = c.nodeType
|
||||
}
|
||||
authOnly := make(map[string]interface{})
|
||||
c.injectAuth(authOnly)
|
||||
merged, _ := json.Marshal(authOnly)
|
||||
bodyReader = bytes.NewReader(merged)
|
||||
}
|
||||
|
||||
+68
-6
@@ -62,8 +62,29 @@ type Settings struct {
|
||||
PullInterval int `json:"pull_interval"`
|
||||
}
|
||||
|
||||
// MachineNode is a single entry returned by GET /api/v2/server/machine/nodes.
|
||||
type MachineNode struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// MachineNodesResponse is the response from GET /api/v2/server/machine/nodes.
|
||||
type MachineNodesResponse struct {
|
||||
Nodes []MachineNode `json:"nodes"`
|
||||
BaseConfig MachineBaseConfig `json:"base_config"`
|
||||
}
|
||||
|
||||
// MachineBaseConfig holds polling intervals for machine mode.
|
||||
type MachineBaseConfig struct {
|
||||
PushInterval int `json:"push_interval"`
|
||||
PullInterval int `json:"pull_interval"`
|
||||
}
|
||||
|
||||
// NodeConfig is the response from GET /api/v1/server/UniProxy/config
|
||||
type NodeConfig struct {
|
||||
// NodeID is populated in machine-mode WS events for routing.
|
||||
NodeID int `json:"node_id,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
ListenIP string `json:"listen_ip"`
|
||||
ServerPort int `json:"server_port"`
|
||||
@@ -73,10 +94,11 @@ type NodeConfig struct {
|
||||
Routes []RouteRule `json:"routes"`
|
||||
|
||||
// Kernel settings (Xboard extension)
|
||||
KernelType string `json:"kernel_type,omitempty"` // "singbox" or "xray"
|
||||
KernelLogLevel string `json:"kernel_log_level,omitempty"` // "info", "warn", etc.
|
||||
CustomOutbounds []OutboundConfig `json:"custom_outbounds,omitempty"`
|
||||
CustomRoutes []map[string]any `json:"custom_routes,omitempty"`
|
||||
KernelType string `json:"kernel_type,omitempty"` // "singbox" or "xray"
|
||||
KernelLogLevel string `json:"kernel_log_level,omitempty"` // "info", "warn", etc.
|
||||
CustomOutbounds []OutboundConfig `json:"custom_outbounds,omitempty"`
|
||||
CustomRoutes []map[string]any `json:"custom_routes,omitempty"`
|
||||
CustomRouteRules []CustomRouteRule `json:"custom_route_rules,omitempty"`
|
||||
|
||||
// Certificate settings (Xboard extension)
|
||||
CertConfig *CertConfig `json:"cert_config,omitempty"`
|
||||
@@ -155,9 +177,11 @@ type BrutalConfig struct {
|
||||
DownMbps int `json:"down_mbps"`
|
||||
}
|
||||
|
||||
// CertConfig holds certificate automation settings from the panel
|
||||
// CertConfig holds certificate automation settings from the panel.
|
||||
// The panel may send the mode field as either "cert_mode" or "mode";
|
||||
// a custom UnmarshalJSON handles both.
|
||||
type CertConfig struct {
|
||||
CertMode string `json:"cert_mode"` // none, dns, http, self, file
|
||||
CertMode string `json:"cert_mode"` // none, dns, http, self, file, content
|
||||
Domain string `json:"domain"` // Certificate domain
|
||||
Email string `json:"email"` // ACME email
|
||||
DNSProvider string `json:"dns_provider"` // dns mode: cloudflare, alidns, etc.
|
||||
@@ -169,6 +193,22 @@ type CertConfig struct {
|
||||
KeyContent string `json:"key_content"` // content mode: private key raw string
|
||||
}
|
||||
|
||||
func (c *CertConfig) UnmarshalJSON(data []byte) error {
|
||||
type plain CertConfig
|
||||
var raw struct {
|
||||
plain
|
||||
ModeFallback string `json:"mode"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = CertConfig(raw.plain)
|
||||
if c.CertMode == "" && raw.ModeFallback != "" {
|
||||
c.CertMode = raw.ModeFallback
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutboundConfig defines a custom outbound for kernel
|
||||
type OutboundConfig struct {
|
||||
Tag string `json:"tag"` // Unique tag for routing
|
||||
@@ -189,6 +229,28 @@ type RouteRule struct {
|
||||
ActionValue string `json:"action_value,omitempty"`
|
||||
}
|
||||
|
||||
type CustomRouteRule struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
Match RouteMatch `json:"match,omitempty"`
|
||||
Action RouteAction `json:"action"`
|
||||
}
|
||||
|
||||
type RouteMatch struct {
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
DomainSuffixes []string `json:"domain_suffixes,omitempty"`
|
||||
IPCIDRs []string `json:"ip_cidrs,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Networks []string `json:"networks,omitempty"`
|
||||
SourceCIDRs []string `json:"source_cidrs,omitempty"`
|
||||
SourcePorts []string `json:"source_ports,omitempty"`
|
||||
}
|
||||
|
||||
type RouteAction struct {
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
// User represents a user returned by the panel
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
+92
-32
@@ -20,6 +20,7 @@ const (
|
||||
WSEventSyncUsers = "sync.users"
|
||||
WSEventSyncUserDelta = "sync.user.delta"
|
||||
WSEventSyncDevices = "sync.devices" // panel → node: global device state
|
||||
WSEventSyncNodes = "sync.nodes" // panel → machine: node list changed
|
||||
WSEventReportDevices = "report.devices" // node → panel: report device snapshot
|
||||
)
|
||||
|
||||
@@ -33,6 +34,10 @@ type WSEvent struct {
|
||||
|
||||
// Device sync fields
|
||||
DeviceUsers map[int][]string // userID -> IPs (for sync.devices)
|
||||
NodeID int
|
||||
|
||||
// Machine node discovery fields (for sync.nodes)
|
||||
Nodes []MachineNode
|
||||
}
|
||||
|
||||
// WSStatusChange notifies the service when WS connectivity changes.
|
||||
@@ -47,27 +52,36 @@ type wsMessage struct {
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// Payload structures for data events
|
||||
// Payload structures for data events.
|
||||
type syncConfigPayload struct {
|
||||
Config NodeConfig `json:"config"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
NodeID int `json:"node_id"`
|
||||
}
|
||||
|
||||
type syncUsersPayload struct {
|
||||
Users []User `json:"users"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
NodeID int `json:"node_id"`
|
||||
}
|
||||
|
||||
type syncUserDeltaPayload struct {
|
||||
Action string `json:"action"`
|
||||
Users []User `json:"users"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
NodeID int `json:"node_id"`
|
||||
}
|
||||
|
||||
// syncDevicesPayload carries global device state from panel.
|
||||
type syncDevicesPayload struct {
|
||||
Users map[int][]string `json:"users"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
NodeID int `json:"node_id"`
|
||||
}
|
||||
|
||||
// syncNodesPayload carries the updated node list for a machine.
|
||||
type syncNodesPayload struct {
|
||||
Nodes []MachineNode `json:"nodes"`
|
||||
}
|
||||
|
||||
// WSClientConfig holds WebSocket client tuning options.
|
||||
@@ -76,6 +90,7 @@ type WSClientConfig struct {
|
||||
HandshakeTimeout time.Duration
|
||||
BackoffInitial time.Duration
|
||||
BackoffMax time.Duration
|
||||
MachineID int
|
||||
}
|
||||
|
||||
// WSClient connects to the panel's Workerman WS server using native WebSocket.
|
||||
@@ -182,14 +197,17 @@ func (w *WSClient) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (w *WSClient) connect(ctx context.Context) error {
|
||||
// Build WS URL with auth query params
|
||||
u, err := url.Parse(w.wsURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse ws url: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("token", w.token)
|
||||
q.Set("node_id", strconv.Itoa(w.nodeID))
|
||||
if w.cfg.MachineID > 0 {
|
||||
q.Set("machine_id", strconv.Itoa(w.cfg.MachineID))
|
||||
} else {
|
||||
q.Set("node_id", strconv.Itoa(w.nodeID))
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
nlog.Core().Debug("ws connecting", "url", u.String())
|
||||
@@ -238,7 +256,11 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
reportTicker := time.NewTicker(w.cfg.StatusInterval)
|
||||
defer reportTicker.Stop()
|
||||
|
||||
msgCh := make(chan wsMessage, 16)
|
||||
// writeCh decouples data collection from network I/O.
|
||||
writeCh := make(chan wsMessage, 16)
|
||||
w.writeCh = writeCh
|
||||
defer func() { w.writeCh = nil }()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -253,21 +275,17 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
return
|
||||
}
|
||||
nlog.Core().Debug("ws recv", "event", msg.Event, "data", string(msg.Data))
|
||||
select {
|
||||
case msgCh <- msg:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
nlog.Core().Warn("ws: message channel full, dropping message", "event", msg.Event)
|
||||
w.handleMessage(msg)
|
||||
if msg.Event == "ping" {
|
||||
select {
|
||||
case writeCh <- wsMessage{Event: "pong"}:
|
||||
default:
|
||||
nlog.Core().Warn("ws write channel full, skipping pong")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// writeCh decouples data collection from network I/O.
|
||||
writeCh := make(chan wsMessage, 16)
|
||||
w.writeCh = writeCh
|
||||
defer func() { w.writeCh = nil }()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -280,17 +298,6 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("read: %w", err)
|
||||
|
||||
case msg := <-msgCh:
|
||||
w.handleMessage(msg)
|
||||
if msg.Event == "ping" {
|
||||
// Queue an immediate pong
|
||||
select {
|
||||
case writeCh <- wsMessage{Event: "pong"}:
|
||||
default:
|
||||
nlog.Core().Warn("ws write channel full, skipping pong")
|
||||
}
|
||||
}
|
||||
|
||||
case <-reportTicker.C:
|
||||
// Send periodic node.status via WebSocket
|
||||
if w.onPing != nil {
|
||||
@@ -339,6 +346,9 @@ func (w *WSClient) handleMessage(msg wsMessage) {
|
||||
case WSEventSyncDevices:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
case WSEventSyncNodes:
|
||||
w.handleDataEvent(msg)
|
||||
|
||||
default:
|
||||
nlog.Core().Debug("ws unknown event", "event", msg.Event)
|
||||
}
|
||||
@@ -370,6 +380,11 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
return
|
||||
}
|
||||
event.Config = &p.Config
|
||||
if p.NodeID > 0 {
|
||||
event.NodeID = p.NodeID
|
||||
} else if p.Config.NodeID > 0 {
|
||||
event.NodeID = p.Config.NodeID
|
||||
}
|
||||
|
||||
case WSEventSyncUsers:
|
||||
nlog.Core().Debug("ws sync users event received")
|
||||
@@ -383,6 +398,7 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
return
|
||||
}
|
||||
event.Users = p.Users
|
||||
event.NodeID = p.NodeID
|
||||
|
||||
case WSEventSyncUserDelta:
|
||||
nlog.Core().Debug("ws sync user delta event received")
|
||||
@@ -401,6 +417,7 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
}
|
||||
event.DeltaAction = p.Action
|
||||
event.DeltaUsers = p.Users
|
||||
event.NodeID = p.NodeID
|
||||
|
||||
case WSEventSyncDevices:
|
||||
nlog.Core().Debug("ws sync devices event received")
|
||||
@@ -410,27 +427,49 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
return
|
||||
}
|
||||
event.DeviceUsers = p.Users
|
||||
event.NodeID = p.NodeID
|
||||
|
||||
case WSEventSyncNodes:
|
||||
nlog.Core().Info("ws sync nodes event received (machine node list changed)")
|
||||
var p syncNodesPayload
|
||||
if err := decodeData(msg.Data, &p); err != nil {
|
||||
nlog.Core().Warn("ws: cannot decode nodes payload", "error", err)
|
||||
return
|
||||
}
|
||||
event.Nodes = p.Nodes
|
||||
}
|
||||
|
||||
w.onEvent(event)
|
||||
}
|
||||
|
||||
// SendDeviceReport sends local device snapshot to panel via WS.
|
||||
// Format: {"123": ["1.1.1.1", "2.2.2.2"], "456": ["3.3.3.3"]}
|
||||
func (w *WSClient) SendDeviceReport(devices map[int][]string) {
|
||||
w.SendDeviceReportForNode(0, devices)
|
||||
}
|
||||
|
||||
// SendDeviceReportForNode sends a device report tagged with a specific node_id.
|
||||
// nodeID == 0 omits the field (legacy single-node mode).
|
||||
func (w *WSClient) SendDeviceReportForNode(nodeID int, devices map[int][]string) {
|
||||
if !w.connected.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
msg := wsMessage{
|
||||
Event: WSEventReportDevices,
|
||||
Timestamp: time.Now().Unix(),
|
||||
var payload interface{}
|
||||
if nodeID > 0 {
|
||||
payload = map[string]interface{}{"node_id": nodeID, "devices": devices}
|
||||
} else {
|
||||
payload = devices
|
||||
}
|
||||
d, err := json.Marshal(devices)
|
||||
|
||||
d, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg.Data = d
|
||||
msg := wsMessage{
|
||||
Event: WSEventReportDevices,
|
||||
Data: d,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
select {
|
||||
case w.writeCh <- msg:
|
||||
@@ -439,6 +478,27 @@ func (w *WSClient) SendDeviceReport(devices map[int][]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SendNodeStatus sends a node.status event with the given node_id (machine mode).
|
||||
func (w *WSClient) SendNodeStatus(nodeID int, stats map[string]interface{}) {
|
||||
if !w.connected.Load() || stats == nil {
|
||||
return
|
||||
}
|
||||
if nodeID > 0 {
|
||||
stats["node_id"] = nodeID
|
||||
}
|
||||
data, _ := json.Marshal(stats)
|
||||
msg := wsMessage{
|
||||
Event: "node.status",
|
||||
Data: data,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
select {
|
||||
case w.writeCh <- msg:
|
||||
default:
|
||||
nlog.Core().Warn("ws write channel full, skipping node status")
|
||||
}
|
||||
}
|
||||
|
||||
// SendRaw sends a raw message to the panel via WebSocket.
|
||||
func (w *WSClient) SendRaw(event string, data json.RawMessage) {
|
||||
if !w.connected.Load() {
|
||||
|
||||
Reference in New Issue
Block a user