feat: finish project basic structure and core functions
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
package komari
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/postLog"
|
||||
)
|
||||
|
||||
// NodeInfo represents a single node from Komari's /api/nodes endpoint.
|
||||
type NodeInfo struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
CPUName string `json:"cpu_name"`
|
||||
Virtualization string `json:"virtualization"`
|
||||
Arch string `json:"arch"`
|
||||
CPUCores int `json:"cpu_cores"`
|
||||
CPUPhysicalCores int `json:"cpu_physical_cores"`
|
||||
OS string `json:"os"`
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
GPUName string `json:"gpu_name"`
|
||||
Region string `json:"region"`
|
||||
MemTotal int64 `json:"mem_total"`
|
||||
SwapTotal int64 `json:"swap_total"`
|
||||
DiskTotal int64 `json:"disk_total"`
|
||||
Weight float64 `json:"weight"`
|
||||
Price float64 `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
AutoRenewal bool `json:"auto_renewal"`
|
||||
Currency string `json:"currency"`
|
||||
ExpiredAt *string `json:"expired_at"`
|
||||
Group string `json:"group"`
|
||||
Tags string `json:"tags"`
|
||||
Hidden bool `json:"hidden"`
|
||||
TrafficLimit int64 `json:"traffic_limit"`
|
||||
TrafficLimitType string `json:"traffic_limit_type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskResult holds the result of a task execution from Komari.
|
||||
type TaskResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Client string `json:"client"`
|
||||
ClientInfo ClientInfo `json:"client_info"`
|
||||
Result string `json:"result"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ClientInfo holds client information returned with task results.
|
||||
type ClientInfo struct {
|
||||
Name string `json:"name"`
|
||||
CPUName string `json:"cpu_name"`
|
||||
Virtualization string `json:"virtualization"`
|
||||
Arch string `json:"arch"`
|
||||
CPUCores int `json:"cpu_cores"`
|
||||
CPUPhysicalCores int `json:"cpu_physical_cores"`
|
||||
OS string `json:"os"`
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
GPUName string `json:"gpu_name"`
|
||||
Region string `json:"region"`
|
||||
MemTotal int64 `json:"mem_total"`
|
||||
SwapTotal int64 `json:"swap_total"`
|
||||
DiskTotal int64 `json:"disk_total"`
|
||||
Weight int `json:"weight"`
|
||||
Price int `json:"price"`
|
||||
BillingCycle int `json:"billing_cycle"`
|
||||
AutoRenewal bool `json:"auto_renewal"`
|
||||
Currency string `json:"currency"`
|
||||
ExpiredAt *string `json:"expired_at"`
|
||||
Group string `json:"group"`
|
||||
Tags string `json:"tags"`
|
||||
Hidden bool `json:"hidden"`
|
||||
TrafficLimit int64 `json:"traffic_limit"`
|
||||
TrafficLimitType string `json:"traffic_limit_type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// KomariResponse is the standard response envelope from Komari API.
|
||||
type KomariResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// Client is the HTTP client for interacting with the Komari Dashboard API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
sessionToken string
|
||||
httpClient *http.Client
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient creates a new Komari API client.
|
||||
func NewClient(baseURL string) *Client {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Login authenticates to Komari and stores the session token cookie.
|
||||
func (c *Client) Login(username, password string) error {
|
||||
postLog.Info("Logging into Komari Dashboard at " + c.baseURL)
|
||||
|
||||
body := map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(body)
|
||||
|
||||
resp, err := c.httpClient.Post(c.baseURL+"/api/login", "application/json", bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("komari login request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var kr KomariResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
|
||||
return fmt.Errorf("failed to parse komari login response: %w", err)
|
||||
}
|
||||
|
||||
if kr.Status != "success" {
|
||||
return fmt.Errorf("komari login failed: %s", kr.Message)
|
||||
}
|
||||
|
||||
// Extract session_token from cookies.
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == "session_token" {
|
||||
c.mu.Lock()
|
||||
c.sessionToken = cookie.Value
|
||||
c.mu.Unlock()
|
||||
postLog.Info("Successfully logged into Komari Dashboard")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("komari login response missing session_token cookie")
|
||||
}
|
||||
|
||||
// getSessionToken returns the current session token.
|
||||
func (c *Client) getSessionToken() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.sessionToken
|
||||
}
|
||||
|
||||
// FetchNodes retrieves all nodes from Komari.
|
||||
func (c *Client) FetchNodes() ([]NodeInfo, error) {
|
||||
req, err := http.NewRequest("GET", c.baseURL+"/api/nodes", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create nodes request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("komari nodes request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var kr KomariResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse komari nodes response: %w", err)
|
||||
}
|
||||
|
||||
if kr.Status != "success" {
|
||||
return nil, fmt.Errorf("komari nodes request failed: %s", kr.Message)
|
||||
}
|
||||
|
||||
var nodes []NodeInfo
|
||||
if err := json.Unmarshal(kr.Data, &nodes); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse komari nodes data: %w", err)
|
||||
}
|
||||
|
||||
postLog.Info(fmt.Sprintf("Fetched %d nodes from Komari", len(nodes)))
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// FetchRecent retrieves the recent status for a specific node.
|
||||
func (c *Client) FetchRecent(uuid string) (json.RawMessage, error) {
|
||||
req, err := http.NewRequest("GET", c.baseURL+"/api/recent/"+url.PathEscape(uuid), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create recent request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("komari recent request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var kr KomariResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse komari recent response: %w", err)
|
||||
}
|
||||
|
||||
if kr.Status != "success" {
|
||||
return nil, fmt.Errorf("komari recent request failed: %s", kr.Message)
|
||||
}
|
||||
|
||||
return kr.Data, nil
|
||||
}
|
||||
|
||||
// ExecTask sends a command execution request to Komari and returns the task ID.
|
||||
func (c *Client) ExecTask(uuids []string, command string) (string, error) {
|
||||
body := map[string]interface{}{
|
||||
"clients": uuids,
|
||||
"command": command,
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(body)
|
||||
|
||||
resp, err := c.httpClient.Post(c.baseURL+"/api/admin/task/exec", "application/json", bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("komari task exec request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var kr KomariResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
|
||||
return "", fmt.Errorf("failed to parse komari task exec response: %w", err)
|
||||
}
|
||||
|
||||
if kr.Status != "success" {
|
||||
return "", fmt.Errorf("komari task exec failed: %s", kr.Message)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Clients []string `json:"clients"`
|
||||
QueuedClients []string `json:"queued_clients"`
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
if err := json.Unmarshal(kr.Data, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse komari task exec data: %w", err)
|
||||
}
|
||||
|
||||
postLog.Info(fmt.Sprintf("Created Komari task %s for %d clients", result.TaskID, len(uuids)))
|
||||
return result.TaskID, nil
|
||||
}
|
||||
|
||||
// GetTaskResult retrieves the result of a task execution.
|
||||
func (c *Client) GetTaskResult(taskID string) ([]TaskResult, bool, error) {
|
||||
resp, err := c.httpClient.Get(c.baseURL + "/api/admin/task/" + url.PathEscape(taskID) + "/result")
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("komari task result request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var kr KomariResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to parse komari task result response: %w", err)
|
||||
}
|
||||
|
||||
if kr.Status != "success" {
|
||||
return nil, false, fmt.Errorf("komari task result failed: %s", kr.Message)
|
||||
}
|
||||
|
||||
var results []TaskResult
|
||||
if err := json.Unmarshal(kr.Data, &results); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to parse komari task result data: %w", err)
|
||||
}
|
||||
|
||||
// Check if all results have completed (result field is non-empty).
|
||||
allDone := true
|
||||
for _, r := range results {
|
||||
if r.Result == "" {
|
||||
allDone = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return results, allDone, nil
|
||||
}
|
||||
|
||||
// PollTaskResult polls for task results every 1 second until all results are
|
||||
// available or 60 seconds have elapsed.
|
||||
func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) {
|
||||
postLog.Info(fmt.Sprintf("Polling for Komari task %s results...", taskID))
|
||||
|
||||
timeout := time.After(60 * time.Second)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
return nil, fmt.Errorf("task execution timed out after 60 seconds")
|
||||
case <-ticker.C:
|
||||
results, done, err := c.GetTaskResult(taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if done {
|
||||
postLog.Info(fmt.Sprintf("Task %s completed with %d results", taskID, len(results)))
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetHTTPClient returns the underlying HTTP client for use by other components.
|
||||
func (c *Client) GetHTTPClient() *http.Client {
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
// GetBaseURL returns the Komari base URL.
|
||||
func (c *Client) GetBaseURL() string {
|
||||
return c.baseURL
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package komari
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/internal/node"
|
||||
"nukumizu-backend/postLog"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WSMessage is the structure of messages received from Komari WebSocket.
|
||||
type WSMessage struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Online []string `json:"online"`
|
||||
Data map[string]node.Report `json:"data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// WSClient manages a WebSocket connection to Komari for real-time status updates.
|
||||
type WSClient struct {
|
||||
komariURL string
|
||||
conn *websocket.Conn
|
||||
connMu sync.Mutex
|
||||
reconnectCount atomic.Int32
|
||||
maxRetries int
|
||||
stopCh chan struct{}
|
||||
running atomic.Bool
|
||||
onReconnectFail func() // Callback when all reconnection attempts fail.
|
||||
}
|
||||
|
||||
// NewWSClient creates a new WebSocket client for Komari.
|
||||
func NewWSClient(komariURL string, maxRetries int) *WSClient {
|
||||
return &WSClient{
|
||||
komariURL: komariURL,
|
||||
maxRetries: maxRetries,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnReconnectFail sets the callback invoked when all reconnection attempts fail.
|
||||
func (w *WSClient) SetOnReconnectFail(cb func()) {
|
||||
w.onReconnectFail = cb
|
||||
}
|
||||
|
||||
// Connect establishes the WebSocket connection and starts the read loop.
|
||||
func (w *WSClient) Connect() error {
|
||||
postLog.Info("Connecting to Komari WebSocket at " + w.komariURL)
|
||||
|
||||
conn, err := w.dial()
|
||||
if err != nil {
|
||||
postLog.Error("Failed to connect to Komari WebSocket: " + err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
w.connMu.Lock()
|
||||
w.conn = conn
|
||||
w.connMu.Unlock()
|
||||
|
||||
w.reconnectCount.Store(0)
|
||||
w.running.Store(true)
|
||||
|
||||
// Request initial state.
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte("get")); err != nil {
|
||||
postLog.Error("Failed to send 'get' message: " + err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
postLog.Info("Connected to Komari WebSocket")
|
||||
go w.readLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WSClient) dial() (*websocket.Conn, error) {
|
||||
// Build the WebSocket URL from the Komari HTTP URL.
|
||||
wsURL := w.komariURL
|
||||
if len(wsURL) > 7 && wsURL[:7] == "http://" {
|
||||
wsURL = "ws://" + wsURL[7:]
|
||||
} else if len(wsURL) > 8 && wsURL[:8] == "https://" {
|
||||
wsURL = "wss://" + wsURL[8:]
|
||||
}
|
||||
wsURL = wsURL + "/api/clients"
|
||||
|
||||
header := http.Header{}
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
conn, _, err := dialer.Dial(wsURL, header)
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (w *WSClient) readLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
postLog.Error(fmt.Sprintf("WebSocket read loop panic recovered: %v", r))
|
||||
}
|
||||
w.running.Store(false)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
w.connMu.Lock()
|
||||
conn := w.conn
|
||||
w.connMu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
postLog.Warning("Komari WebSocket read error: " + err.Error())
|
||||
w.tryReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
var msg WSMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
postLog.Warning("Failed to parse Komari WebSocket message: " + err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Status != "success" {
|
||||
postLog.Warning("Komari WebSocket message with non-success status")
|
||||
continue
|
||||
}
|
||||
|
||||
// Update the node tracker with the received data.
|
||||
tracker := node.GetTracker()
|
||||
if tracker != nil {
|
||||
tracker.UpdateStatus(msg.Data.Online, msg.Data.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WSClient) tryReconnect() {
|
||||
count := w.reconnectCount.Add(1)
|
||||
if int(count) > w.maxRetries {
|
||||
postLog.Error(fmt.Sprintf("Komari WebSocket reconnection failed after %d attempts", w.maxRetries))
|
||||
if w.onReconnectFail != nil {
|
||||
w.onReconnectFail()
|
||||
}
|
||||
// Reset count and start a long-delay retry.
|
||||
w.reconnectCount.Store(0)
|
||||
time.Sleep(30 * time.Second)
|
||||
go w.tryReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// Exponential backoff.
|
||||
delay := time.Duration(1<<(count-1)) * time.Second
|
||||
if delay > 30*time.Second {
|
||||
delay = 30 * time.Second
|
||||
}
|
||||
|
||||
postLog.Info(fmt.Sprintf("Attempting Komari WebSocket reconnect %d/%d in %v...",
|
||||
count, w.maxRetries, delay))
|
||||
|
||||
time.Sleep(delay)
|
||||
|
||||
conn, err := w.dial()
|
||||
if err != nil {
|
||||
postLog.Warning(fmt.Sprintf("Komari WebSocket reconnect attempt %d failed: %v", count, err))
|
||||
go w.tryReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
w.connMu.Lock()
|
||||
if w.conn != nil {
|
||||
w.conn.Close()
|
||||
}
|
||||
w.conn = conn
|
||||
w.connMu.Unlock()
|
||||
|
||||
w.reconnectCount.Store(0)
|
||||
|
||||
// Request full state after reconnect.
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte("get")); err != nil {
|
||||
postLog.Error("Failed to send 'get' after reconnect: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Re-fetch node list after reconnect.
|
||||
client := GetClient()
|
||||
if client != nil {
|
||||
nodes, err := client.FetchNodes()
|
||||
if err != nil {
|
||||
postLog.Error("Failed to refresh node list after reconnect: " + err.Error())
|
||||
} else {
|
||||
tracker := node.GetTracker()
|
||||
if tracker != nil {
|
||||
nodeNames := make(map[string]string, len(nodes))
|
||||
for _, n := range nodes {
|
||||
nodeNames[n.UUID] = n.Name
|
||||
}
|
||||
tracker.UpdateNodeList(nodeNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postLog.Info("Komari WebSocket reconnected successfully")
|
||||
go w.readLoop()
|
||||
}
|
||||
|
||||
// Stop closes the WebSocket connection and stops the read loop.
|
||||
func (w *WSClient) Stop() {
|
||||
w.running.Store(false)
|
||||
close(w.stopCh)
|
||||
|
||||
w.connMu.Lock()
|
||||
defer w.connMu.Unlock()
|
||||
if w.conn != nil {
|
||||
w.conn.Close()
|
||||
w.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning returns whether the WebSocket client is actively running.
|
||||
func (w *WSClient) IsRunning() bool {
|
||||
return w.running.Load()
|
||||
}
|
||||
|
||||
// --- Global Komari client management ---
|
||||
|
||||
var globalClient *Client
|
||||
var globalWSClient *WSClient
|
||||
var globalClientMu sync.Mutex
|
||||
|
||||
// InitClient initializes the global Komari HTTP client.
|
||||
func InitClient(komariURL string) {
|
||||
globalClientMu.Lock()
|
||||
defer globalClientMu.Unlock()
|
||||
globalClient = NewClient(komariURL)
|
||||
}
|
||||
|
||||
// GetClient returns the global Komari HTTP client.
|
||||
func GetClient() *Client {
|
||||
return globalClient
|
||||
}
|
||||
|
||||
// InitWSClient initializes the global Komari WebSocket client.
|
||||
func InitWSClient(komariURL string, maxRetries int) {
|
||||
globalClientMu.Lock()
|
||||
defer globalClientMu.Unlock()
|
||||
globalWSClient = NewWSClient(komariURL, maxRetries)
|
||||
}
|
||||
|
||||
// GetWSClient returns the global Komari WebSocket client.
|
||||
func GetWSClient() *WSClient {
|
||||
return globalWSClient
|
||||
}
|
||||
|
||||
// LoginAndStart performs the Komari login and returns an error if it fails.
|
||||
func LoginAndStart() error {
|
||||
cfg := config.GetConfig()
|
||||
client := GetClient()
|
||||
if client == nil {
|
||||
return fmt.Errorf("komari client not initialized")
|
||||
}
|
||||
|
||||
if err := client.Login(cfg.Komari.Account.Username, cfg.Komari.Account.Password); err != nil {
|
||||
return fmt.Errorf("komari login failed: %w", err)
|
||||
}
|
||||
|
||||
// Fetch initial node list.
|
||||
nodes, err := client.FetchNodes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch nodes from komari: %w", err)
|
||||
}
|
||||
|
||||
tracker := node.GetTracker()
|
||||
if tracker != nil {
|
||||
nodeNames := make(map[string]string, len(nodes))
|
||||
for _, n := range nodes {
|
||||
nodeNames[n.UUID] = n.Name
|
||||
}
|
||||
tracker.UpdateNodeList(nodeNames)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user