mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 01:02:46 +00:00
edit nodes config,api,checks,dash
This commit is contained in:
parent
cdd90de099
commit
09c5c05691
9 changed files with 486 additions and 96 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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" }}',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" = "Нода успешно создана"
|
||||
|
|
|
|||
Loading…
Reference in a new issue