edit nodes config,api,checks,dash

This commit is contained in:
Konstantin Pichugin 2026-01-06 03:08:00 +03:00
parent cdd90de099
commit 09c5c05691
9 changed files with 486 additions and 96 deletions

View file

@ -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()

View file

@ -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()

View file

@ -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)
}

View file

@ -9,10 +9,10 @@
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched">
<a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable>
<h2>Nodes Management</h2>
<h2>{{ i18n "pages.nodes.title" }}</h2>
<div style="margin-bottom: 20px;">
<h3>Add New Node</h3>
<h3>{{ i18n "pages.nodes.addNewNode" }}</h3>
<a-input id="node-name" placeholder='{{ i18n "pages.nodes.nodeName" }}' style="width: 200px; margin-right: 10px;"></a-input>
<a-input id="node-address" placeholder='{{ i18n "pages.nodes.nodeAddress" }} (http://192.168.1.100)' style="width: 250px; margin-right: 10px;"></a-input>
<a-input id="node-port" placeholder='{{ i18n "pages.nodes.nodePort" }} (8080)' type="number" style="width: 120px; margin-right: 10px;"></a-input>
@ -23,6 +23,7 @@
<div style="margin-bottom: 20px;">
<a-button icon="sync" @click="loadNodes" :loading="refreshing">{{ i18n "refresh" }}</a-button>
<a-button icon="check-circle" @click="checkAllNodes" :loading="checkingAll" style="margin-left: 10px;">{{ i18n "pages.nodes.checkAll" }}</a-button>
<a-button icon="reload" @click="reloadAllNodes" :loading="reloadingAll" style="margin-left: 10px;">{{ i18n "pages.nodes.reloadAll" }}</a-button>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="node => node.id"
@ -41,6 +42,10 @@
<a-icon type="check-circle"></a-icon>
{{ i18n "pages.nodes.check" }}
</a-menu-item>
<a-menu-item key="reload">
<a-icon type="reload"></a-icon>
{{ i18n "pages.nodes.reload" }}
</a-menu-item>
<a-menu-item key="edit">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
@ -64,6 +69,26 @@
</a-tag>
</template>
<a-tag v-else color="default">{{ i18n "none" }}</a-tag>
</template>
<template slot="name" slot-scope="text, node">
<template v-if="editingNodeId === node.id">
<div style="display: inline-flex; align-items: center;">
<a-input :id="`node-name-input-${node.id}`"
v-model="editingNodeName"
@keydown.enter.native="saveNodeName(node.id)"
@keydown.esc.native="cancelEditNodeName()"
:style="{ width: '120px', marginRight: '8px' }" />
<a-icon type="check-circle" theme="filled" @click="saveNodeName(node.id)"
:style="{ color: '#52c41a', cursor: 'pointer', fontSize: '18px', marginRight: '8px' }"
title="Сохранить" />
<a-icon type="close-circle" theme="filled" @click="cancelEditNodeName()"
:style="{ color: '#ff4d4f', cursor: 'pointer', fontSize: '18px' }"
title="Отменить" />
</div>
</template>
<template v-else>
<span>[[ node.name || '-' ]]</span>
</template>
</template>
</a-table>
</a-card>
@ -78,6 +103,8 @@
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/js/model/node.js?{{ .cur_ver }}"></script>
{{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() {

View file

@ -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" }}',

View file

@ -146,28 +146,28 @@
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="6" header='Multi-Node Mode'>
<a-collapse-panel key="6" header='{{ i18n "pages.settings.multiNodeMode" }}'>
<a-setting-list-item paddings="small">
<template #title>Multi-Node Mode</template>
<template #description>Enable distributed architecture with separate worker nodes. When enabled, XRAY Core runs on nodes instead of locally.</template>
<template #title>{{ i18n "pages.settings.multiNodeMode" }}</template>
<template #description>{{ i18n "pages.settings.multiNodeModeDesc" }}</template>
<template #control>
<a-switch v-model="allSetting.multiNodeMode" @change="(enabled) => onMultiNodeModeChange(enabled)"></a-switch>
</template>
</a-setting-list-item>
<a-alert v-if="allSetting.multiNodeMode" type="info" :style="{ marginTop: '10px' }" show-icon>
<template slot="message">
Multi-Node Mode Enabled
{{ i18n "pages.settings.multiNodeModeEnabled" }}
</template>
<template slot="description">
<div>In this mode:</div>
<div>{{ i18n "pages.settings.multiNodeModeInThisMode" }}</div>
<ul style="margin: 8px 0 0 20px; padding: 0;">
<li>XRAY Core will not run locally</li>
<li>Configurations will be sent to worker nodes</li>
<li>You need to assign inbounds to nodes</li>
<li>Subscriptions will use node endpoints</li>
<li>{{ i18n "pages.settings.multiNodeModePoint1" }}</li>
<li>{{ i18n "pages.settings.multiNodeModePoint2" }}</li>
<li>{{ i18n "pages.settings.multiNodeModePoint3" }}</li>
<li>{{ i18n "pages.settings.multiNodeModePoint4" }}</li>
</ul>
<div style="margin-top: 8px;">
<a-button type="link" size="small" @click="goToNodes">Go to Nodes Management</a-button>
<a-button type="link" size="small" @click="goToNodes">{{ i18n "pages.settings.goToNodesManagement" }}</a-button>
</div>
</template>
</a-alert>

View file

@ -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{

View file

@ -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"

View file

@ -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" = "Нода успешно создана"