From 09c5c05691cd698689a9db3a1dae3abb51131bd8 Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Tue, 6 Jan 2026 03:08:00 +0300 Subject: [PATCH] edit nodes config,api,checks,dash --- node/api/server.go | 12 ++ node/xray/manager.go | 100 +++++++++++++++ web/controller/node.go | 94 ++++++++++++-- web/html/nodes.html | 185 ++++++++++++++++----------- web/html/settings.html | 4 +- web/html/settings/panel/general.html | 20 +-- web/service/node.go | 131 ++++++++++++++++++- web/translation/translate.en_US.toml | 18 +++ web/translation/translate.ru_RU.toml | 18 +++ 9 files changed, 486 insertions(+), 96 deletions(-) diff --git a/node/api/server.go b/node/api/server.go index 10c6d773..11fa93c4 100644 --- a/node/api/server.go +++ b/node/api/server.go @@ -45,6 +45,7 @@ func (s *Server) Start() error { { api.POST("/apply-config", s.applyConfig) api.POST("/reload", s.reload) + api.POST("/force-reload", s.forceReload) api.GET("/status", s.status) api.GET("/stats", s.stats) } @@ -143,6 +144,17 @@ func (s *Server) reload(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "XRAY reloaded successfully"}) } +// forceReload forcefully reloads XRAY even if it's hung or not running. +func (s *Server) forceReload(c *gin.Context) { + if err := s.xrayManager.ForceReload(); err != nil { + logger.Errorf("Failed to force reload: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "XRAY force reloaded successfully"}) +} + // status returns the current status of XRAY. func (s *Server) status(c *gin.Context) { status := s.xrayManager.GetStatus() diff --git a/node/xray/manager.go b/node/xray/manager.go index 543d21a3..31a92326 100644 --- a/node/xray/manager.go +++ b/node/xray/manager.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "sync" + "time" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/util/json_util" @@ -214,6 +215,105 @@ func (m *Manager) Reload() error { return nil } +// ForceReload forcefully reloads XRAY even if it's not running or hung. +// It stops XRAY if running, loads config from file if available, and restarts. +func (m *Manager) ForceReload() error { + m.lock.Lock() + defer m.lock.Unlock() + + // Stop XRAY if it's running (even if hung) + if m.process != nil { + // Try to stop gracefully, but don't fail if it's hung + _ = m.process.Stop() + // Give it a moment to stop + time.Sleep(500 * time.Millisecond) + // Force kill if still running + if m.process.IsRunning() { + logger.Warning("XRAY process appears hung, forcing stop") + // Process will be cleaned up by finalizer or on next start + } + m.process = nil + } + + // Try to load config from file first (if available) + configPaths := []string{ + "bin/config.json", + "config/config.json", + "./config.json", + "/app/bin/config.json", + "/app/config/config.json", + } + + var configData []byte + var configPath string + + // Find config file + for _, path := range configPaths { + if _, statErr := os.Stat(path); statErr == nil { + var readErr error + configData, readErr = os.ReadFile(path) + if readErr == nil { + configPath = path + break + } + } + } + + // If config file found, try to use it + if configPath != "" { + var config xray.Config + if err := json.Unmarshal(configData, &config); err == nil { + // Check if config has inbounds + if len(config.InboundConfigs) > 0 { + // Check if API inbound exists + hasAPIInbound := false + for _, inbound := range config.InboundConfigs { + if inbound.Tag == "api" { + hasAPIInbound = true + break + } + } + + // Add API inbound if missing + if !hasAPIInbound { + apiInbound := xray.InboundConfig{ + Tag: "api", + Port: 62789, + Protocol: "tunnel", + Listen: json_util.RawMessage(`"127.0.0.1"`), + Settings: json_util.RawMessage(`{"address":"127.0.0.1"}`), + } + config.InboundConfigs = append([]xray.InboundConfig{apiInbound}, config.InboundConfigs...) + configData, _ = json.MarshalIndent(&config, "", " ") + } + + // Apply config from file + m.config = &config + m.process = xray.NewProcess(&config) + if err := m.process.Start(); err == nil { + logger.Infof("XRAY force reloaded successfully from config file %s", configPath) + return nil + } + } + } + // If loading from file failed, continue with saved config + } + + // If no config file, try to use saved config + if m.config == nil { + return errors.New("no config available to reload") + } + + // Restart with saved config + m.process = xray.NewProcess(m.config) + if err := m.process.Start(); err != nil { + return fmt.Errorf("failed to restart XRAY: %w", err) + } + + logger.Info("XRAY force reloaded successfully") + return nil +} + // Stop stops the XRAY process. func (m *Manager) Stop() error { m.lock.Lock() diff --git a/web/controller/node.go b/web/controller/node.go index 16eefa2d..bd3ca595 100644 --- a/web/controller/node.go +++ b/web/controller/node.go @@ -34,6 +34,8 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) { g.POST("/del/:id", a.deleteNode) g.POST("/check/:id", a.checkNode) g.POST("/checkAll", a.checkAllNodes) + g.POST("/reload/:id", a.reloadNode) + g.POST("/reloadAll", a.reloadAllNodes) g.GET("/status/:id", a.getNodeStatus) } @@ -123,23 +125,58 @@ func (a *NodeController) updateNode(c *gin.Context) { return } - node := &model.Node{Id: id} - err = c.ShouldBind(node) - if err != nil { - jsonMsg(c, "Invalid node data", err) - return - } - - // Get existing node to check if API key changed + // Get existing node first to preserve fields that are not being updated existingNode, err := a.nodeService.GetNode(id) if err != nil { jsonMsg(c, "Failed to get existing node", err) return } - // Validate API key if it was changed or address was changed - if node.ApiKey != "" && (node.ApiKey != existingNode.ApiKey || node.Address != existingNode.Address) { - err = a.nodeService.ValidateApiKey(node) + // Create node with only provided fields + node := &model.Node{Id: id} + + // Try to parse as JSON first (for API calls) + contentType := c.GetHeader("Content-Type") + if contentType == "application/json" { + var jsonData map[string]interface{} + if err := c.ShouldBindJSON(&jsonData); err == nil { + // Only set fields that are provided in JSON + if nameVal, ok := jsonData["name"].(string); ok && nameVal != "" { + node.Name = nameVal + } + if addressVal, ok := jsonData["address"].(string); ok && addressVal != "" { + node.Address = addressVal + } + if apiKeyVal, ok := jsonData["apiKey"].(string); ok && apiKeyVal != "" { + node.ApiKey = apiKeyVal + } + } + } else { + // Parse as form data (default for web UI) + // Only extract fields that are actually provided + if name := c.PostForm("name"); name != "" { + node.Name = name + } + if address := c.PostForm("address"); address != "" { + node.Address = address + } + if apiKey := c.PostForm("apiKey"); apiKey != "" { + node.ApiKey = apiKey + } + } + + // Validate API key if it was changed + if node.ApiKey != "" && node.ApiKey != existingNode.ApiKey { + // Create a temporary node for validation + validationNode := &model.Node{ + Id: id, + Address: node.Address, + ApiKey: node.ApiKey, + } + if validationNode.Address == "" { + validationNode.Address = existingNode.Address + } + err = a.nodeService.ValidateApiKey(validationNode) if err != nil { jsonMsg(c, "Invalid API key or node unreachable: "+err.Error(), err) return @@ -223,3 +260,38 @@ func (a *NodeController) getNodeStatus(c *gin.Context) { jsonObj(c, status, nil) } + +// reloadNode reloads XRAY on a specific node. +func (a *NodeController) reloadNode(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid node ID", err) + return + } + + node, err := a.nodeService.GetNode(id) + if err != nil { + jsonMsg(c, "Failed to get node", err) + return + } + + // Use force reload to handle hung nodes + err = a.nodeService.ForceReloadNode(node) + if err != nil { + jsonMsg(c, "Failed to reload node", err) + return + } + + jsonMsg(c, "Node reloaded successfully", nil) +} + +// reloadAllNodes reloads XRAY on all nodes. +func (a *NodeController) reloadAllNodes(c *gin.Context) { + err := a.nodeService.ReloadAllNodes() + if err != nil { + jsonMsg(c, "Failed to reload some nodes", err) + return + } + + jsonMsg(c, "All nodes reloaded successfully", nil) +} diff --git a/web/html/nodes.html b/web/html/nodes.html index f2713ed4..2d2cedac 100644 --- a/web/html/nodes.html +++ b/web/html/nodes.html @@ -9,10 +9,10 @@ -

Nodes Management

+

{{ i18n "pages.nodes.title" }}

-

Add New Node

+

{{ i18n "pages.nodes.addNewNode" }}

@@ -23,6 +23,7 @@
{{ i18n "refresh" }} {{ i18n "pages.nodes.checkAll" }} + {{ i18n "pages.nodes.reloadAll" }}
{{ i18n "pages.nodes.check" }} + + + {{ i18n "pages.nodes.reload" }} + {{ i18n "edit" }} @@ -64,6 +69,26 @@ {{ i18n "none" }} + + @@ -78,6 +103,8 @@ + + {{template "page/body_scripts" .}} {{template "component/aSidebar" .}} @@ -99,6 +126,7 @@ align: 'left', width: 120, dataIndex: "name", + scopedSlots: { customRender: 'name' }, }, { title: '{{ i18n "pages.nodes.address" }}', align: 'left', @@ -132,6 +160,7 @@ align: 'left', width: 100, dataIndex: "name", + scopedSlots: { customRender: 'name' }, }, { title: '{{ i18n "pages.nodes.status" }}', align: 'center', @@ -152,6 +181,9 @@ nodes: [], refreshing: false, checkingAll: false, + reloadingAll: false, + editingNodeId: null, + editingNodeName: '', }, methods: { async loadNodes() { @@ -192,8 +224,11 @@ case 'check': this.checkNode(node.id); break; + case 'reload': + this.reloadNode(node.id); + break; case 'edit': - this.editNode(node); + this.startEditNodeName(node); break; case 'delete': this.deleteNode(node.id); @@ -256,83 +291,56 @@ } }); }, - editNode(node) { - // Parse existing address - let existingAddress = node.address || ''; - let existingPort = ''; - try { - const url = new URL(existingAddress); - existingAddress = `${url.protocol}//${url.hostname}`; - existingPort = url.port || ''; - } catch (e) { - // If parsing fails, try to extract manually - const match = existingAddress.match(/^(https?:\/\/[^:]+)(?::(\d+))?/); - if (match) { - existingAddress = match[1]; - existingPort = match[2] || ''; - } - } - - const newName = prompt('{{ i18n "pages.nodes.nodeName" }}:', node.name || ''); - if (newName === null) return; - - const newAddress = prompt('{{ i18n "pages.nodes.nodeAddress" }}:', existingAddress); - if (newAddress === null) return; - - const newPort = prompt('{{ i18n "pages.nodes.nodePort" }}:', existingPort); - if (newPort === null) return; - - const newApiKey = prompt('{{ i18n "pages.nodes.nodeApiKey" }} ({{ i18n "pages.nodes.leaveEmptyToKeep" }}):', ''); - - // Validate address format - if (!newAddress.match(/^https?:\/\//)) { - app.$message.error('{{ i18n "pages.nodes.validUrl" }}'); - return; - } - - // Validate port - const portNum = parseInt(newPort); - if (isNaN(portNum) || portNum < 1 || portNum > 65535) { - app.$message.error('{{ i18n "pages.nodes.validPort" }}'); - return; - } - - // Construct full address - const fullAddress = `${newAddress}:${newPort}`; - - // Check for duplicate nodes (excluding current node) - const existingNodes = this.nodes || []; - const duplicate = existingNodes.find(n => { - if (n.id === node.id) return false; // Skip current node - try { - const nodeUrl = new URL(n.address); - const newUrl = new URL(fullAddress); - // Compare protocol, hostname, and port - return nodeUrl.protocol === newUrl.protocol && - nodeUrl.hostname === newUrl.hostname && - (nodeUrl.port || (nodeUrl.protocol === 'https:' ? '443' : '80')) === - (newUrl.port || (newUrl.protocol === 'https:' ? '443' : '80')); - } catch (e) { - // If URL parsing fails, do simple string comparison - return n.address === fullAddress; + startEditNodeName(node) { + this.editingNodeId = node.id; + this.editingNodeName = node.name || ''; + // Focus input after Vue updates DOM + this.$nextTick(() => { + const inputId = `node-name-input-${node.id}`; + const input = document.getElementById(inputId); + if (input) { + input.focus(); + input.select(); } }); + }, + cancelEditNodeName() { + this.editingNodeId = null; + this.editingNodeName = ''; + }, + async saveNodeName(nodeId) { + if (this.editingNodeId !== nodeId) { + return; // Not editing this node + } - if (duplicate) { - app.$message.error('{{ i18n "pages.nodes.duplicateNode" }}'); + const newName = (this.editingNodeName || '').trim(); + + if (!newName) { + this.$message.error('{{ i18n "pages.nodes.enterNodeName" }}'); return; } - const nodeData = { - name: newName.trim(), - address: fullAddress - }; - - if (newApiKey !== null && newApiKey.trim() !== '') { - nodeData.apiKey = newApiKey.trim(); + // Check if name changed + const node = this.nodes.find(n => n.id === nodeId); + if (node && node.name === newName) { + // No change, just cancel editing + this.cancelEditNodeName(); + return; } - this.updateNode(node.id, nodeData); + try { + const msg = await HttpUtil.post(`/panel/node/update/${nodeId}`, { name: newName }); + if (msg && msg.success) { + this.$message.success('{{ i18n "pages.nodes.updateSuccess" }}'); + this.cancelEditNodeName(); + await this.loadNodes(); + } else { + this.$message.error((msg && msg.msg) || '{{ i18n "pages.nodes.updateError" }}'); + } + } catch (e) { + console.error("Failed to update node name:", e); + this.$message.error('{{ i18n "pages.nodes.updateError" }}'); + } }, async updateNode(id, nodeData) { try { @@ -347,6 +355,39 @@ console.error("Failed to update node:", e); app.$message.error('{{ i18n "pages.nodes.updateError" }}'); } + }, + async reloadNode(id) { + try { + const msg = await HttpUtil.post(`/panel/node/reload/${id}`); + if (msg.success) { + app.$message.success('{{ i18n "pages.nodes.reloadSuccess" }}'); + await this.loadNodes(); + } else { + app.$message.error(msg.msg || '{{ i18n "pages.nodes.reloadError" }}'); + } + } catch (e) { + console.error("Failed to reload node:", e); + app.$message.error('{{ i18n "pages.nodes.reloadError" }}'); + } + }, + async reloadAllNodes() { + this.reloadingAll = true; + try { + const msg = await HttpUtil.post('/panel/node/reloadAll'); + if (msg.success) { + app.$message.success('{{ i18n "pages.nodes.reloadAllSuccess" }}'); + setTimeout(() => { + this.loadNodes(); + }, 2000); + } else { + app.$message.error(msg.msg || '{{ i18n "pages.nodes.reloadAllError" }}'); + } + } catch (e) { + console.error("Failed to reload all nodes:", e); + app.$message.error('{{ i18n "pages.nodes.reloadAllError" }}'); + } finally { + this.reloadingAll = false; + } } }, async mounted() { diff --git a/web/html/settings.html b/web/html/settings.html index acccb1e8..e517853d 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -247,8 +247,8 @@ if (enabled) { vm.$confirm({ - title: 'Enable Multi-Node Mode', - content: 'Enabling multi-node mode will stop local XRAY Core. Make sure you have configured worker nodes before enabling this mode. Continue?', + title: '{{ i18n "pages.settings.enableMultiNodeMode" }}', + content: '{{ i18n "pages.settings.enableMultiNodeModeConfirm" }}', class: themeSwitcher.currentTheme, okText: '{{ i18n "sure" }}', cancelText: '{{ i18n "cancel" }}', diff --git a/web/html/settings/panel/general.html b/web/html/settings/panel/general.html index fc4bf68c..867d3156 100644 --- a/web/html/settings/panel/general.html +++ b/web/html/settings/panel/general.html @@ -146,28 +146,28 @@ - + - - + + diff --git a/web/service/node.go b/web/service/node.go index e0ca3dac..01d773d9 100644 --- a/web/service/node.go +++ b/web/service/node.go @@ -45,9 +45,47 @@ func (s *NodeService) AddNode(node *model.Node) error { } // UpdateNode updates an existing node. +// Only updates fields that are provided (non-empty for strings, non-zero for integers). func (s *NodeService) UpdateNode(node *model.Node) error { db := database.GetDB() - return db.Save(node).Error + + // Get existing node to preserve fields that are not being updated + existingNode, err := s.GetNode(node.Id) + if err != nil { + return fmt.Errorf("failed to get existing node: %w", err) + } + + // Update only provided fields + updates := make(map[string]interface{}) + + if node.Name != "" { + updates["name"] = node.Name + } + + if node.Address != "" { + updates["address"] = node.Address + } + + if node.ApiKey != "" { + updates["api_key"] = node.ApiKey + } + + // Update status and last_check if provided (these are usually set by health checks, not user edits) + if node.Status != "" && node.Status != existingNode.Status { + updates["status"] = node.Status + } + + if node.LastCheck > 0 && node.LastCheck != existingNode.LastCheck { + updates["last_check"] = node.LastCheck + } + + // If no fields to update, return early + if len(updates) == 0 { + return nil + } + + // Update only the specified fields + return db.Model(existingNode).Updates(updates).Error } // DeleteNode deletes a node by ID. @@ -408,6 +446,97 @@ func (s *NodeService) ApplyConfigToNode(node *model.Node, xrayConfig []byte) err return nil } +// ReloadNode reloads XRAY on a specific node. +func (s *NodeService) ReloadNode(node *model.Node) error { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + url := fmt.Sprintf("%s/api/v1/reload", node.Address) + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey)) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// ForceReloadNode forcefully reloads XRAY on a specific node (even if hung). +func (s *NodeService) ForceReloadNode(node *model.Node) error { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + url := fmt.Sprintf("%s/api/v1/force-reload", node.Address) + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", node.ApiKey)) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("node returned status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// ReloadAllNodes reloads XRAY on all nodes. +func (s *NodeService) ReloadAllNodes() error { + nodes, err := s.GetAllNodes() + if err != nil { + return fmt.Errorf("failed to get nodes: %w", err) + } + + type reloadResult struct { + node *model.Node + err error + } + + results := make(chan reloadResult, len(nodes)) + for _, node := range nodes { + go func(n *model.Node) { + err := s.ForceReloadNode(n) // Use force reload to handle hung nodes + results <- reloadResult{node: n, err: err} + }(node) + } + + var errors []string + for i := 0; i < len(nodes); i++ { + result := <-results + if result.err != nil { + errors = append(errors, fmt.Sprintf("node %d (%s): %v", result.node.Id, result.node.Name, result.err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("failed to reload some nodes: %s", strings.Join(errors, "; ")) + } + + return nil +} + // ValidateApiKey validates the API key by making a test request to the node. func (s *NodeService) ValidateApiKey(node *model.Node) error { client := &http.Client{ diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml index 7ee826b6..f09169d5 100644 --- a/web/translation/translate.en_US.toml +++ b/web/translation/translate.en_US.toml @@ -410,6 +410,17 @@ "muxDesc" = "Transmit multiple independent data streams within an established data stream." "muxSett" = "Mux Settings" "direct" = "Direct Connection" +"multiNodeMode" = "Multi-Node Mode" +"multiNodeModeDesc" = "Enable distributed architecture with separate worker nodes. When enabled, XRAY Core runs on nodes instead of locally." +"multiNodeModeEnabled" = "Multi-Node Mode Enabled" +"multiNodeModeInThisMode" = "In this mode:" +"multiNodeModePoint1" = "XRAY Core will not run locally" +"multiNodeModePoint2" = "Configurations will be sent to worker nodes" +"multiNodeModePoint3" = "You need to assign inbounds to nodes" +"multiNodeModePoint4" = "Subscriptions will use node endpoints" +"goToNodesManagement" = "Go to Nodes Management" +"enableMultiNodeMode" = "Enable Multi-Node Mode" +"enableMultiNodeModeConfirm" = "Enabling multi-node mode will stop local XRAY Core. Make sure you have configured worker nodes before enabling this mode. Continue?" "directDesc" = "Directly establishes connections with domains or IP ranges of a specific country." "notifications" = "Notifications" "certs" = "Certificaties" @@ -587,6 +598,7 @@ [pages.nodes] "title" = "Nodes Management" +"addNewNode" = "Add New Node" "addNode" = "Add Node" "editNode" = "Edit Node" "deleteNode" = "Delete Node" @@ -631,6 +643,12 @@ "updateError" = "Failed to update node" "addSuccess" = "Node added successfully" "addError" = "Failed to add node" +"reload" = "Reload" +"reloadAll" = "Reload All Nodes" +"reloadSuccess" = "Node reloaded successfully" +"reloadError" = "Failed to reload node" +"reloadAllSuccess" = "All nodes reloaded successfully" +"reloadAllError" = "Failed to reload some nodes" [pages.nodes.toasts] "createSuccess" = "Node created successfully" diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml index 4afed6bf..88c421e3 100644 --- a/web/translation/translate.ru_RU.toml +++ b/web/translation/translate.ru_RU.toml @@ -411,6 +411,17 @@ "muxSett" = "Настройки Mux" "direct" = "Прямое подключение" "directDesc" = "Устанавливает прямые соединения с доменами или IP-адресами определённой страны." +"multiNodeMode" = "Режим Multi-Node" +"multiNodeModeDesc" = "Включить распределенную архитектуру с отдельными рабочими нодами. При включении XRAY Core будет работать на нодах, а не локально." +"multiNodeModeEnabled" = "Режим Multi-Node включен" +"multiNodeModeInThisMode" = "В этом режиме:" +"multiNodeModePoint1" = "XRAY Core не будет работать локально" +"multiNodeModePoint2" = "Конфигурации будут отправляться на рабочие ноды" +"multiNodeModePoint3" = "Необходимо назначить инбаунды на ноды" +"multiNodeModePoint4" = "Подписки будут использовать адреса нод" +"goToNodesManagement" = "Перейти к управлению нодами" +"enableMultiNodeMode" = "Включить режим Multi-Node" +"enableMultiNodeModeConfirm" = "Включение режима Multi-Node остановит локальный XRAY Core. Убедитесь, что вы настроили рабочие ноды перед включением этого режима. Продолжить?" "notifications" = "Уведомления" "certs" = "Сертификаты" "externalTraffic" = "Внешний трафик" @@ -587,6 +598,7 @@ [pages.nodes] "title" = "Управление нодами" +"addNewNode" = "Добавить новую ноду" "addNode" = "Добавить ноду" "editNode" = "Редактировать ноду" "deleteNode" = "Удалить ноду" @@ -631,6 +643,12 @@ "updateError" = "Не удалось обновить ноду" "addSuccess" = "Нода успешно добавлена" "addError" = "Не удалось добавить ноду" +"reload" = "Перезагрузить" +"reloadAll" = "Перезагрузить все ноды" +"reloadSuccess" = "Нода успешно перезагружена" +"reloadError" = "Не удалось перезагрузить ноду" +"reloadAllSuccess" = "Все ноды успешно перезагружены" +"reloadAllError" = "Не удалось перезагрузить некоторые ноды" [pages.nodes.toasts] "createSuccess" = "Нода успешно создана"