edit nodes config,api,checks,dash

This commit is contained in:
Konstantin Pichugin 2026-01-06 02:27:12 +03:00
parent d7175e7803
commit cdd90de099
15 changed files with 991 additions and 212 deletions

View file

@ -46,6 +46,7 @@ func (s *Server) Start() error {
api.POST("/apply-config", s.applyConfig)
api.POST("/reload", s.reload)
api.GET("/status", s.status)
api.GET("/stats", s.stats)
}
s.httpServer = &http.Server{
@ -147,3 +148,18 @@ func (s *Server) status(c *gin.Context) {
status := s.xrayManager.GetStatus()
c.JSON(http.StatusOK, status)
}
// stats returns traffic and online clients statistics from XRAY.
func (s *Server) stats(c *gin.Context) {
// Get reset parameter (default: false)
reset := c.DefaultQuery("reset", "false") == "true"
stats, err := s.xrayManager.GetStats(reset)
if err != nil {
logger.Errorf("Failed to get stats: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View file

@ -12,10 +12,10 @@ services:
- "8080:8080"
- "44000:44000"
volumes:
- ./config:/app/config
- ./bin/config.json:/app/bin/config.json
- ./logs:/app/logs
# Note: ./bin volume is removed to preserve xray binary from image
# If you need to persist bin directory, use a different path or copy files manually
# Note: config.json is mounted directly for persistence
# If the file doesn't exist, it will be created when XRAY config is first applied
networks:
- xray-network
node2:
@ -31,10 +31,10 @@ services:
- "8081:8080"
- "44001:44000"
volumes:
- ./config:/app/config
- ./bin/config.json:/app/bin/config.json
- ./logs:/app/logs
# Note: ./bin volume is removed to preserve xray binary from image
# If you need to persist bin directory, use a different path or copy files manually
# Note: config.json is mounted directly for persistence
# If the file doesn't exist, it will be created when XRAY config is first applied
networks:
- xray-network
@ -51,10 +51,10 @@ services:
- "8082:8080"
- "44002:44000"
volumes:
- ./config:/app/config
- ./bin/config.json:/app/bin/config.json
- ./logs:/app/logs
# Note: ./bin volume is removed to preserve xray binary from image
# If you need to persist bin directory, use a different path or copy files manually
# Note: config.json is mounted directly for persistence
# If the file doesn't exist, it will be created when XRAY config is first applied
networks:
- xray-network
networks:

View file

@ -5,12 +5,21 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/json_util"
"github.com/mhsanaei/3x-ui/v2/xray"
)
// NodeStats represents traffic and online clients statistics from a node.
type NodeStats struct {
Traffic []*xray.Traffic `json:"traffic"`
ClientTraffic []*xray.ClientTraffic `json:"clientTraffic"`
OnlineClients []string `json:"onlineClients"`
}
// Manager manages the XRAY Core process lifecycle.
type Manager struct {
process *xray.Process
@ -20,7 +29,99 @@ type Manager struct {
// NewManager creates a new XRAY manager instance.
func NewManager() *Manager {
return &Manager{}
m := &Manager{}
// Try to load config from file on startup
m.LoadConfigFromFile()
return m
}
// LoadConfigFromFile attempts to load XRAY configuration from config.json file.
// It checks multiple possible locations: bin/config.json, config/config.json, and ./config.json
func (m *Manager) LoadConfigFromFile() error {
// Possible config file paths (in order of priority)
configPaths := []string{
"bin/config.json",
"config/config.json",
"./config.json",
"/app/bin/config.json",
"/app/config/config.json",
}
var configData []byte
var configPath string
// Try each path until we find a valid 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 no config file found, that's okay - node will wait for config from panel
if configPath == "" {
logger.Debug("No config.json found, node will wait for configuration from panel")
return nil
}
// Validate JSON
var configJSON json.RawMessage
if err := json.Unmarshal(configData, &configJSON); err != nil {
logger.Warningf("Config file %s contains invalid JSON: %v", configPath, err)
return fmt.Errorf("invalid JSON in config file: %w", err)
}
// Parse full config
var config xray.Config
if err := json.Unmarshal(configData, &config); err != nil {
logger.Warningf("Failed to parse config from %s: %v", configPath, err)
return fmt.Errorf("failed to parse config: %w", err)
}
// Check if API inbound exists, if not add it
hasAPIInbound := false
for _, inbound := range config.InboundConfigs {
if inbound.Tag == "api" {
hasAPIInbound = true
break
}
}
// If no API inbound found, add a default one
if !hasAPIInbound {
logger.Debug("No API inbound found in config, adding default API inbound")
apiInbound := xray.InboundConfig{
Tag: "api",
Port: 62789, // Default API port
Protocol: "tunnel",
Listen: json_util.RawMessage(`"127.0.0.1"`),
Settings: json_util.RawMessage(`{"address":"127.0.0.1"}`),
}
// Add API inbound at the beginning
config.InboundConfigs = append([]xray.InboundConfig{apiInbound}, config.InboundConfigs...)
// Update configData with the new inbound
configData, _ = json.MarshalIndent(&config, "", " ")
}
// Check if config has inbounds (after adding API inbound)
if len(config.InboundConfigs) == 0 {
logger.Debug("Config file found but no inbounds configured, skipping XRAY start")
return nil
}
// Apply the loaded config (this will start XRAY)
logger.Infof("Loading XRAY configuration from %s", configPath)
if err := m.ApplyConfig(configData); err != nil {
logger.Errorf("Failed to apply config from file: %v", err)
return fmt.Errorf("failed to apply config: %w", err)
}
logger.Info("XRAY started successfully from config file")
return nil
}
// IsRunning returns true if XRAY is currently running.
@ -124,3 +225,63 @@ func (m *Manager) Stop() error {
return m.process.Stop()
}
// GetStats returns traffic and online clients statistics from XRAY.
func (m *Manager) GetStats(reset bool) (*NodeStats, error) {
m.lock.Lock()
defer m.lock.Unlock()
if m.process == nil || !m.process.IsRunning() {
return nil, errors.New("XRAY is not running")
}
// Get API port from process
apiPort := m.process.GetAPIPort()
if apiPort == 0 {
return nil, errors.New("XRAY API port is not available")
}
// Create XrayAPI instance and initialize
xrayAPI := &xray.XrayAPI{}
if err := xrayAPI.Init(apiPort); err != nil {
return nil, fmt.Errorf("failed to initialize XrayAPI: %w", err)
}
defer xrayAPI.Close()
// Get traffic statistics
traffics, clientTraffics, err := xrayAPI.GetTraffic(reset)
if err != nil {
return nil, fmt.Errorf("failed to get traffic: %w", err)
}
// Get online clients from process
onlineClients := m.process.GetOnlineClients()
// Also check online clients from traffic (clients with traffic > 0)
onlineFromTraffic := make(map[string]bool)
for _, ct := range clientTraffics {
if ct.Up+ct.Down > 0 {
onlineFromTraffic[ct.Email] = true
}
}
// Merge online clients
onlineSet := make(map[string]bool)
for _, email := range onlineClients {
onlineSet[email] = true
}
for email := range onlineFromTraffic {
onlineSet[email] = true
}
onlineList := make([]string, 0, len(onlineSet))
for email := range onlineSet {
onlineList = append(onlineList, email)
}
return &NodeStats{
Traffic: traffics,
ClientTraffic: clientTraffics,
OnlineClients: onlineList,
}, nil
}

File diff suppressed because one or more lines are too long

View file

@ -1684,6 +1684,40 @@ class Inbound extends XrayCommonClass {
return addresses;
}
// Get node addresses with their IDs - returns array of {address, nodeId}
getNodeAddressesWithIds() {
// Check if we have nodeIds and availableNodes
if (!this.nodeIds || !Array.isArray(this.nodeIds) || this.nodeIds.length === 0) {
return [];
}
// Try to get availableNodes from global app object
let availableNodes = null;
if (typeof app !== 'undefined' && app.availableNodes) {
availableNodes = app.availableNodes;
} else if (typeof window !== 'undefined' && window.app && window.app.availableNodes) {
availableNodes = window.app.availableNodes;
}
if (!availableNodes || availableNodes.length === 0) {
return [];
}
// Get addresses with node IDs for all node IDs
const result = [];
for (const nodeId of this.nodeIds) {
const node = availableNodes.find(n => n.id === nodeId);
if (node && node.address) {
const host = this.extractNodeHost(node.address);
if (host) {
result.push({ address: host, nodeId: nodeId });
}
}
}
return result;
}
// Get first node address (for backward compatibility)
getNodeAddress() {
const addresses = this.getNodeAddresses();
@ -1694,17 +1728,17 @@ class Inbound extends XrayCommonClass {
let result = [];
let email = client ? client.email : '';
// Get all node addresses
const nodeAddresses = this.getNodeAddresses();
// Get all node addresses with their IDs
const nodeAddressesWithIds = this.getNodeAddressesWithIds();
// Determine addresses to use
let addresses = [];
if (nodeAddresses.length > 0) {
addresses = nodeAddresses;
let addressesWithIds = [];
if (nodeAddressesWithIds.length > 0) {
addressesWithIds = nodeAddressesWithIds;
} else if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
addresses = [this.listen];
addressesWithIds = [{ address: this.listen, nodeId: null }];
} else {
addresses = [location.hostname];
addressesWithIds = [{ address: location.hostname, nodeId: null }];
}
let port = this.port;
@ -1718,11 +1752,12 @@ class Inbound extends XrayCommonClass {
if (ObjectUtil.isArrEmpty(this.stream.externalProxy)) {
// Generate links for each node address
addresses.forEach((addr) => {
addressesWithIds.forEach((addrInfo) => {
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
result.push({
remark: r,
link: this.genLink(addr, port, 'same', r, client)
link: this.genLink(addrInfo.address, port, 'same', r, client),
nodeId: addrInfo.nodeId
});
});
} else {
@ -1732,7 +1767,8 @@ class Inbound extends XrayCommonClass {
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
result.push({
remark: r,
link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client)
link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client),
nodeId: null
});
});
}

View file

@ -78,6 +78,19 @@
</a-row>
</a-card>
</a-col>
<a-col v-if="multiNodeMode" :sm="24" :lg="12">
<a-card hoverable>
<a-row :gutter="[0, isMobile ? 16 : 0]">
<a-col :span="24" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.nodesColor"
:percent="status.nodesPercent"></a-progress>
<div>
<b>{{ i18n "pages.index.nodesAvailability" }}:</b> [[ status.nodes.online ]] / [[ status.nodes.total ]]
</div>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card hoverable>
<template #title>
@ -685,6 +698,7 @@
this.appStats = { threads: 0, mem: 0, uptime: 0 };
this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" };
this.nodes = { online: 0, total: 0 };
if (data == null) {
return;
@ -707,6 +721,11 @@
this.appUptime = data.appUptime;
this.appStats = data.appStats;
this.xray = data.xray;
if (data.nodes) {
this.nodes = { online: data.nodes.online || 0, total: data.nodes.total || 0 };
} else {
this.nodes = { online: 0, total: 0 };
}
switch (this.xray.state) {
case 'running':
this.xray.color = "green";
@ -726,6 +745,24 @@
break;
}
}
get nodesPercent() {
if (this.nodes.total === 0) {
return 0;
}
return NumberFormatter.toFixed(this.nodes.online / this.nodes.total * 100, 2);
}
get nodesColor() {
const percent = this.nodesPercent;
if (percent === 100) {
return '#008771'; // Green
} else if (percent >= 50) {
return "#f37b24"; // Orange
} else {
return "#cf3c3c"; // Red
}
}
}
const versionModal = {
@ -895,12 +932,23 @@
showAlert: false,
showIp: false,
ipLimitEnable: false,
multiNodeMode: false,
},
methods: {
loading(spinning, tip = '{{ i18n "loading"}}') {
this.loadingStates.spinning = spinning;
this.loadingTip = tip;
},
async loadMultiNodeMode() {
try {
const msg = await HttpUtil.post("/panel/setting/all");
if (msg && msg.success && msg.obj) {
this.multiNodeMode = Boolean(msg.obj.multiNodeMode) || false;
}
} catch (e) {
console.warn("Failed to load multi-node mode:", e);
}
},
async getStatus() {
try {
const msg = await HttpUtil.get('/panel/api/server/status');
@ -1127,6 +1175,9 @@
this.ipLimitEnable = msg.obj.ipLimitEnable;
}
// Load multi-node mode setting
await this.loadMultiNodeMode();
// Initial status fetch
await this.getStatus();

View file

@ -124,12 +124,25 @@
});
});
} else {
this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, client).forEach(l => {
const links = this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, client);
const hasMultipleNodes = links.length > 1 && links.some(l => l.nodeId !== null);
links.forEach(l => {
// Use node name if multiple nodes, otherwise use remark
let displayRemark = l.remark;
if (hasMultipleNodes && l.nodeId !== null) {
const node = app.availableNodes && app.availableNodes.find(n => n.id === l.nodeId);
if (node && node.name) {
displayRemark = node.name;
}
}
this.qrcodes.push({
remark: l.remark,
remark: displayRemark,
link: l.link,
useIPv4: false,
originalLink: l.link
originalLink: l.link,
nodeId: l.nodeId
});
});
}

View file

@ -5,27 +5,76 @@
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' nodes-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-card>
<a-layout-content :style="{ padding: '24px 16px' }">
<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>
<div style="margin-bottom: 20px;">
<h3>Add New Node</h3>
<a-input id="node-name" placeholder="Node Name" style="width: 200px; margin-right: 10px;"></a-input>
<a-input id="node-address" placeholder="http://192.168.1.100:8080" style="width: 300px; margin-right: 10px;"></a-input>
<a-input-password id="node-apikey" placeholder="API Key" style="width: 200px; margin-right: 10px;"></a-input-password>
<a-button type="primary" onclick="addNode()">Add Node</a-button>
<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>
<a-input-password id="node-apikey" placeholder='{{ i18n "pages.nodes.nodeApiKey" }}' style="width: 200px; margin-right: 10px;"></a-input-password>
<a-button type="primary" onclick="addNode()">{{ i18n "pages.nodes.addNode" }}</a-button>
</div>
<div style="margin-bottom: 20px;">
<a-button onclick="loadNodes()">Refresh</a-button>
<a-button onclick="checkAllNodes()" style="margin-left: 10px;">Check All</a-button>
<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>
</div>
<div id="nodes-list">
<a-spin tip="Loading..."></a-spin>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="node => node.id"
:data-source="nodes" :scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
:style="{ marginTop: '10px' }"
class="nodes-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, node">
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
<a-menu slot="overlay" @click="a => clickAction(a, node)"
:theme="themeSwitcher.currentTheme">
<a-menu-item key="check">
<a-icon type="check-circle"></a-icon>
{{ i18n "pages.nodes.check" }}
</a-menu-item>
<a-menu-item key="edit">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item key="delete" :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
{{ i18n "delete" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
<template slot="status" slot-scope="text, node">
<a-tag :color="getStatusColor(node.status)">
[[ node.status || 'unknown' ]]
</a-tag>
</template>
<template slot="inbounds" slot-scope="text, node">
<template v-if="node.inbounds && node.inbounds.length > 0">
<a-tag v-for="(inbound, index) in node.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }">
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
</a-tag>
</template>
<a-tag v-else color="default">{{ i18n "none" }}</a-tag>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-row v-else>
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
</a-row>
</a-layout-content>
</a-layout>
</a-layout>
@ -34,188 +83,343 @@
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
<script>
const columns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
responsive: ["xs"],
}, {
title: '{{ i18n "pages.nodes.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.nodes.name" }}',
align: 'left',
width: 120,
dataIndex: "name",
}, {
title: '{{ i18n "pages.nodes.address" }}',
align: 'left',
width: 200,
dataIndex: "address",
}, {
title: '{{ i18n "pages.nodes.status" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'status' },
}, {
title: '{{ i18n "pages.nodes.assignedInbounds" }}',
align: 'left',
width: 300,
scopedSlots: { customRender: 'inbounds' },
}];
const mobileColumns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
responsive: ["s"],
}, {
title: '{{ i18n "pages.nodes.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.nodes.name" }}',
align: 'left',
width: 100,
dataIndex: "name",
}, {
title: '{{ i18n "pages.nodes.status" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'status' },
}];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher
themeSwitcher,
loadingStates: {
fetched: false,
spinning: false
},
nodes: [],
refreshing: false,
checkingAll: false,
},
methods: {
async loadNodes() {
this.refreshing = true;
try {
const msg = await HttpUtil.get('/panel/node/list');
if (msg && msg.success && msg.obj) {
this.nodes = msg.obj.map(node => ({
id: node.id,
name: node.name || '',
address: node.address || '',
status: node.status || 'unknown',
inbounds: node.inbounds || []
}));
}
} catch (e) {
console.error("Failed to load nodes:", e);
app.$message.error('{{ i18n "pages.nodes.loadError" }}');
} finally {
this.refreshing = false;
this.loadingStates.fetched = true;
}
},
getStatusColor(status) {
switch (status) {
case 'online':
return 'green';
case 'offline':
return 'orange';
case 'error':
return 'red';
default:
return 'default';
}
},
clickAction(action, node) {
switch (action.key) {
case 'check':
this.checkNode(node.id);
break;
case 'edit':
this.editNode(node);
break;
case 'delete':
this.deleteNode(node.id);
break;
}
},
async checkNode(id) {
try {
const msg = await HttpUtil.post(`/panel/node/check/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.checkSuccess" }}');
await this.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.checkError" }}');
}
} catch (e) {
console.error("Failed to check node:", e);
app.$message.error('{{ i18n "pages.nodes.checkError" }}');
}
},
async checkAllNodes() {
this.checkingAll = true;
try {
const msg = await HttpUtil.post('/panel/node/checkAll');
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.checkingAll" }}');
setTimeout(() => {
this.loadNodes();
}, 2000);
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.checkError" }}');
}
} catch (e) {
console.error("Failed to check all nodes:", e);
app.$message.error('{{ i18n "pages.nodes.checkError" }}');
} finally {
this.checkingAll = false;
}
},
async deleteNode(id) {
this.$confirm({
title: '{{ i18n "pages.nodes.deleteConfirm" }}',
content: '{{ i18n "pages.nodes.deleteConfirmText" }}',
okText: '{{ i18n "sure" }}',
okType: 'danger',
cancelText: '{{ i18n "close" }}',
onOk: async () => {
try {
const msg = await HttpUtil.post(`/panel/node/del/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.deleteSuccess" }}');
await this.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.deleteError" }}');
}
} catch (e) {
console.error("Failed to delete node:", e);
app.$message.error('{{ i18n "pages.nodes.deleteError" }}');
}
}
});
async function loadNodes() {
const listDiv = document.getElementById('nodes-list');
listDiv.innerHTML = '<a-spin tip="Loading..."></a-spin>';
},
editNode(node) {
// Parse existing address
let existingAddress = node.address || '';
let existingPort = '';
try {
const response = await fetch('/panel/node/list');
const data = await response.json();
if (data.success && data.obj) {
let html = '<table style="width: 100%; border-collapse: collapse;"><thead><tr><th style="border: 1px solid #ddd; padding: 8px;">ID</th><th style="border: 1px solid #ddd; padding: 8px;">Name</th><th style="border: 1px solid #ddd; padding: 8px;">Address</th><th style="border: 1px solid #ddd; padding: 8px;">Status</th><th style="border: 1px solid #ddd; padding: 8px;">Assigned Inbounds</th><th style="border: 1px solid #ddd; padding: 8px;">Actions</th></tr></thead><tbody>';
data.obj.forEach(node => {
const status = node.status || 'unknown';
const statusColor = status === 'online' ? 'green' : status === 'offline' ? 'red' : 'gray';
// Format assigned inbounds
let inboundsText = 'None';
if (node.inbounds && node.inbounds.length > 0) {
inboundsText = node.inbounds.map(inbound => {
const remark = inbound.remark || `Port ${inbound.port}`;
return `${remark} (ID: ${inbound.id})`;
}).join(', ');
}
html += `<tr>
<td style="border: 1px solid #ddd; padding: 8px;">${node.id}</td>
<td style="border: 1px solid #ddd; padding: 8px;">${node.name || ''}</td>
<td style="border: 1px solid #ddd; padding: 8px;">${node.address || ''}</td>
<td style="border: 1px solid #ddd; padding: 8px; color: ${statusColor};">${status}</td>
<td style="border: 1px solid #ddd; padding: 8px; max-width: 300px; word-wrap: break-word;">${inboundsText}</td>
<td style="border: 1px solid #ddd; padding: 8px;">
<button onclick="checkNode(${node.id})" style="margin-right: 5px;">Check</button>
<button onclick="editNode(${node.id}, '${(node.name || '').replace(/'/g, "\\'")}', '${(node.address || '').replace(/'/g, "\\'")}')" style="margin-right: 5px;">Edit</button>
<button onclick="deleteNode(${node.id})" style="color: red;">Delete</button>
</td>
</tr>`;
});
html += '</tbody></table>';
listDiv.innerHTML = html;
} else {
listDiv.innerHTML = '<p>No nodes found</p>';
}
} catch (error) {
listDiv.innerHTML = '<p style="color: red;">Error loading nodes: ' + error.message + '</p>';
console.error('Error:', error);
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] || '';
}
}
async function addNode() {
const name = document.getElementById('node-name').value;
const address = document.getElementById('node-address').value;
const apiKey = document.getElementById('node-apikey').value;
if (!name || !address || !apiKey) {
alert('Please fill all fields');
return;
}
try {
const response = await fetch('/panel/node/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, address, apiKey })
});
const data = await response.json();
if (data.success) {
alert('Node added successfully');
document.getElementById('node-name').value = '';
document.getElementById('node-address').value = '';
document.getElementById('node-apikey').value = '';
loadNodes();
} else {
alert('Error: ' + (data.msg || 'Failed to add node'));
}
} catch (error) {
alert('Error: ' + error.message);
console.error('Error:', error);
}
}
async function checkNode(id) {
try {
const response = await fetch(`/panel/node/check/${id}`, { method: 'POST' });
const data = await response.json();
if (data.success) {
alert('Node checked');
loadNodes();
} else {
alert('Error: ' + (data.msg || 'Failed to check node'));
}
} catch (error) {
alert('Error: ' + error.message);
console.error('Error:', error);
}
}
async function checkAllNodes() {
try {
const response = await fetch('/panel/node/checkAll', { method: 'POST' });
const data = await response.json();
if (data.success) {
alert('Checking all nodes...');
setTimeout(loadNodes, 2000);
} else {
alert('Error: ' + (data.msg || 'Failed to check nodes'));
}
} catch (error) {
alert('Error: ' + error.message);
console.error('Error:', error);
}
}
async function deleteNode(id) {
if (!confirm('Are you sure you want to delete this node?')) {
return;
}
try {
const response = await fetch(`/panel/node/del/${id}`, { method: 'POST' });
const data = await response.json();
if (data.success) {
alert('Node deleted');
loadNodes();
} else {
alert('Error: ' + (data.msg || 'Failed to delete node'));
}
} catch (error) {
alert('Error: ' + error.message);
console.error('Error:', error);
}
}
async function editNode(id, name, address) {
const newName = prompt('Enter new name:', name);
const newName = prompt('{{ i18n "pages.nodes.nodeName" }}:', node.name || '');
if (newName === null) return;
const newAddress = prompt('Enter new address:', address);
const newAddress = prompt('{{ i18n "pages.nodes.nodeAddress" }}:', existingAddress);
if (newAddress === null) return;
const apiKey = prompt('Enter API Key (leave empty to keep current):');
const newPort = prompt('{{ i18n "pages.nodes.nodePort" }}:', existingPort);
if (newPort === null) return;
const data = { name: newName, address: newAddress };
if (apiKey) {
data.apiKey = apiKey;
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;
}
});
if (duplicate) {
app.$message.error('{{ i18n "pages.nodes.duplicateNode" }}');
return;
}
const nodeData = {
name: newName.trim(),
address: fullAddress
};
if (newApiKey !== null && newApiKey.trim() !== '') {
nodeData.apiKey = newApiKey.trim();
}
this.updateNode(node.id, nodeData);
},
async updateNode(id, nodeData) {
try {
const msg = await HttpUtil.post(`/panel/node/update/${id}`, nodeData);
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.updateSuccess" }}');
await this.loadNodes();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.nodes.updateError" }}');
}
} catch (e) {
console.error("Failed to update node:", e);
app.$message.error('{{ i18n "pages.nodes.updateError" }}');
}
}
},
async mounted() {
await this.loadNodes();
}
});
async function addNode() {
const name = document.getElementById('node-name').value.trim();
const address = document.getElementById('node-address').value.trim();
const port = document.getElementById('node-port').value.trim();
const apiKey = document.getElementById('node-apikey').value;
if (!name || !address || !port || !apiKey) {
app.$message.error('{{ i18n "pages.nodes.enterNodeName" }}');
return;
}
// Validate address format
if (!address.match(/^https?:\/\//)) {
app.$message.error('{{ i18n "pages.nodes.validUrl" }}');
return;
}
// Validate port
const portNum = parseInt(port);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
app.$message.error('{{ i18n "pages.nodes.validPort" }}');
return;
}
// Construct full address
const fullAddress = `${address}:${port}`;
// Check for duplicate nodes
const existingNodes = app.$data.nodes || [];
const duplicate = existingNodes.find(node => {
try {
const nodeUrl = new URL(node.address);
const newUrl = new URL(fullAddress);
// Compare protocol, hostname, and port
const nodePort = nodeUrl.port || (nodeUrl.protocol === 'https:' ? '443' : '80');
const newPort = newUrl.port || (newUrl.protocol === 'https:' ? '443' : '80');
return nodeUrl.protocol === newUrl.protocol &&
nodeUrl.hostname === newUrl.hostname &&
nodePort === newPort;
} catch (e) {
// If URL parsing fails, do simple string comparison
return node.address === fullAddress;
}
});
if (duplicate) {
app.$message.error('{{ i18n "pages.nodes.duplicateNode" }}');
return;
}
try {
const response = await fetch(`/panel/node/update/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
alert('Node updated');
loadNodes();
const msg = await HttpUtil.post('/panel/node/add', { name, address: fullAddress, apiKey });
if (msg.success) {
app.$message.success('{{ i18n "pages.nodes.addSuccess" }}');
document.getElementById('node-name').value = '';
document.getElementById('node-address').value = '';
document.getElementById('node-port').value = '';
document.getElementById('node-apikey').value = '';
app.loadNodes();
} else {
alert('Error: ' + (result.msg || 'Failed to update node'));
app.$message.error(msg.msg || '{{ i18n "pages.nodes.addError" }}');
}
} catch (error) {
alert('Error: ' + error.message);
console.error('Error:', error);
app.$message.error('{{ i18n "pages.nodes.addError" }}');
}
}
// Load nodes on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadNodes);
} else {
loadNodes();
}
</script>
{{template "page/body_end" .}}

View file

@ -0,0 +1,31 @@
// Package job provides background job implementations for the 3x-ui panel.
package job
import (
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
)
// CollectNodeStatsJob collects traffic and online clients statistics from all nodes.
type CollectNodeStatsJob struct {
nodeService service.NodeService
}
// NewCollectNodeStatsJob creates a new CollectNodeStatsJob instance.
func NewCollectNodeStatsJob() *CollectNodeStatsJob {
return &CollectNodeStatsJob{
nodeService: service.NodeService{},
}
}
// Run executes the job to collect statistics from all nodes.
func (j *CollectNodeStatsJob) Run() {
logger.Debug("Starting node stats collection job")
if err := j.nodeService.CollectNodeStats(); err != nil {
logger.Errorf("Failed to collect node stats: %v", err)
return
}
logger.Debug("Node stats collection job completed successfully")
}

View file

@ -7,11 +7,13 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/xray"
)
// NodeService provides business logic for managing nodes in multi-node mode.
@ -161,6 +163,183 @@ func (s *NodeService) GetInboundsForNode(nodeId int) ([]*model.Inbound, error) {
return inbounds, nil
}
// NodeStatsResponse represents the response from node stats API.
type NodeStatsResponse struct {
Traffic []*NodeTraffic `json:"traffic"`
ClientTraffic []*NodeClientTraffic `json:"clientTraffic"`
OnlineClients []string `json:"onlineClients"`
}
// NodeTraffic represents traffic statistics from a node.
type NodeTraffic struct {
IsInbound bool `json:"isInbound"`
IsOutbound bool `json:"isOutbound"`
Tag string `json:"tag"`
Up int64 `json:"up"`
Down int64 `json:"down"`
}
// NodeClientTraffic represents client traffic statistics from a node.
type NodeClientTraffic struct {
Email string `json:"email"`
Up int64 `json:"up"`
Down int64 `json:"down"`
}
// GetNodeStats retrieves traffic and online clients statistics from a node.
func (s *NodeService) GetNodeStats(node *model.Node, reset bool) (*NodeStatsResponse, error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
url := fmt.Sprintf("%s/api/v1/stats", node.Address)
if reset {
url += "?reset=true"
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+node.ApiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request node stats: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("node returned status code %d: %s", resp.StatusCode, string(body))
}
var stats NodeStatsResponse
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &stats, nil
}
// CollectNodeStats collects statistics from all nodes and aggregates them into the database.
// This should be called periodically (e.g., via cron job).
func (s *NodeService) CollectNodeStats() error {
// Check if multi-node mode is enabled
settingService := SettingService{}
multiMode, err := settingService.GetMultiNodeMode()
if err != nil || !multiMode {
return nil // Skip if multi-node mode is not enabled
}
nodes, err := s.GetAllNodes()
if err != nil {
return fmt.Errorf("failed to get nodes: %w", err)
}
if len(nodes) == 0 {
return nil // No nodes to collect stats from
}
// Filter nodes: only collect stats from nodes that have assigned inbounds
nodesWithInbounds := make([]*model.Node, 0)
for _, node := range nodes {
inbounds, err := s.GetInboundsForNode(node.Id)
if err == nil && len(inbounds) > 0 {
// Only include nodes that have at least one assigned inbound
nodesWithInbounds = append(nodesWithInbounds, node)
}
}
if len(nodesWithInbounds) == 0 {
return nil // No nodes with assigned inbounds
}
// Import inbound service to aggregate traffic
inboundService := &InboundService{}
// Collect stats from nodes with assigned inbounds concurrently
type nodeStatsResult struct {
node *model.Node
stats *NodeStatsResponse
err error
}
results := make(chan nodeStatsResult, len(nodesWithInbounds))
for _, node := range nodesWithInbounds {
go func(n *model.Node) {
stats, err := s.GetNodeStats(n, false) // Don't reset counters on collection
results <- nodeStatsResult{node: n, stats: stats, err: err}
}(node)
}
// Aggregate all traffic
allTraffics := make([]*xray.Traffic, 0)
allClientTraffics := make([]*xray.ClientTraffic, 0)
onlineClientsMap := make(map[string]bool)
for i := 0; i < len(nodesWithInbounds); i++ {
result := <-results
if result.err != nil {
// Check if error is expected (XRAY not running, 404 for old nodes, etc.)
errMsg := result.err.Error()
if strings.Contains(errMsg, "XRAY is not running") ||
strings.Contains(errMsg, "status code 404") ||
strings.Contains(errMsg, "status code 500") {
// These are expected errors, log as debug only
logger.Debugf("Skipping stats collection from node %s (ID: %d): %v", result.node.Name, result.node.Id, result.err)
} else {
// Unexpected errors should be logged as warning
logger.Warningf("Failed to get stats from node %s (ID: %d): %v", result.node.Name, result.node.Id, result.err)
}
continue
}
if result.stats == nil {
continue
}
// Convert node traffic to xray.Traffic
for _, nt := range result.stats.Traffic {
allTraffics = append(allTraffics, &xray.Traffic{
IsInbound: nt.IsInbound,
IsOutbound: nt.IsOutbound,
Tag: nt.Tag,
Up: nt.Up,
Down: nt.Down,
})
}
// Convert node client traffic to xray.ClientTraffic
for _, nct := range result.stats.ClientTraffic {
allClientTraffics = append(allClientTraffics, &xray.ClientTraffic{
Email: nct.Email,
Up: nct.Up,
Down: nct.Down,
})
}
// Collect online clients
for _, email := range result.stats.OnlineClients {
onlineClientsMap[email] = true
}
}
// Aggregate traffic into database
if len(allTraffics) > 0 || len(allClientTraffics) > 0 {
_, needRestart := inboundService.AddTraffic(allTraffics, allClientTraffics)
if needRestart {
logger.Info("Traffic aggregation triggered client renewal/disabling, restart may be needed")
}
}
logger.Debugf("Collected stats from nodes: %d traffics, %d client traffics, %d online clients",
len(allTraffics), len(allClientTraffics), len(onlineClientsMap))
return nil
}
// AssignInboundToNode assigns an inbound to a node.
func (s *NodeService) AssignInboundToNode(inboundId, nodeId int) error {
db := database.GetDB()

View file

@ -92,6 +92,10 @@ type Status struct {
Mem uint64 `json:"mem"`
Uptime uint64 `json:"uptime"`
} `json:"appStats"`
Nodes struct {
Online int `json:"online"`
Total int `json:"total"`
} `json:"nodes"`
}
// Release represents information about a software release from GitHub.
@ -414,6 +418,32 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.AppStats.Uptime = 0
}
// Node statistics (only if multi-node mode is enabled)
settingService := SettingService{}
allSetting, err := settingService.GetAllSetting()
if err == nil && allSetting != nil && allSetting.MultiNodeMode {
nodeService := NodeService{}
nodes, err := nodeService.GetAllNodes()
if err == nil {
status.Nodes.Total = len(nodes)
onlineCount := 0
for _, node := range nodes {
if node.Status == "online" {
onlineCount++
}
}
status.Nodes.Online = onlineCount
} else {
// If error getting nodes, set to 0
status.Nodes.Total = 0
status.Nodes.Online = 0
}
} else {
// If multi-node mode is disabled, set to 0
status.Nodes.Total = 0
status.Nodes.Online = 0
}
return status
}

View file

@ -317,7 +317,21 @@ func (s *XrayService) restartXrayMultiMode(isForce bool) error {
// Build config for this node
nodeConfig := *baseConfig
// Preserve API inbound from template (if exists)
apiInbound := xray.InboundConfig{}
hasAPIInbound := false
for _, inbound := range baseConfig.InboundConfigs {
if inbound.Tag == "api" {
apiInbound = inbound
hasAPIInbound = true
break
}
}
nodeConfig.InboundConfigs = []xray.InboundConfig{}
// Add API inbound first if it exists
if hasAPIInbound {
nodeConfig.InboundConfigs = append(nodeConfig.InboundConfigs, apiInbound)
}
for _, inbound := range inbounds {
// Process clients (same logic as GetXrayConfig)

View file

@ -28,6 +28,7 @@
"edit" = "Edit"
"delete" = "Delete"
"reset" = "Reset"
"refresh" = "Refresh"
"noData" = "No data."
"copySuccess" = "Copied Successful"
"sure" = "Sure"
@ -118,6 +119,7 @@
"swap" = "Swap"
"storage" = "Storage"
"memory" = "RAM"
"nodesAvailability" = "Nodes Availability"
"threads" = "Threads"
"xrayStatus" = "Xray"
"stopXray" = "Stop"
@ -592,10 +594,18 @@
"checkAllNodes" = "Check All Nodes"
"nodeName" = "Node Name"
"nodeAddress" = "Node Address"
"nodePort" = "Port"
"nodeApiKey" = "API Key"
"nodeStatus" = "Status"
"lastCheck" = "Last Check"
"actions" = "Actions"
"operate" = "Actions"
"name" = "Name"
"address" = "Address"
"status" = "Status"
"assignedInbounds" = "Assigned Inbounds"
"checkAll" = "Check All"
"check" = "Check"
"online" = "Online"
"offline" = "Offline"
"error" = "Error"
@ -603,17 +613,28 @@
"enterNodeName" = "Please enter node name"
"enterNodeAddress" = "Please enter node address"
"validUrl" = "Must be a valid URL (http:// or https://)"
"validPort" = "Port must be a number between 1 and 65535"
"duplicateNode" = "A node with this address and port already exists"
"fullUrlHint" = "Full URL to node API (e.g., http://192.168.1.100:8080)"
"enterApiKey" = "Please enter API key"
"apiKeyHint" = "API key configured on the node (NODE_API_KEY environment variable)"
"leaveEmptyToKeep" = "leave empty to keep current"
"loadError" = "Failed to load nodes"
"checkSuccess" = "Node check completed"
"checkError" = "Failed to check node"
"checkingAll" = "Checking all nodes..."
"deleteConfirm" = "Confirm Deletion"
"deleteConfirmText" = "Are you sure you want to delete this node?"
"deleteSuccess" = "Node deleted successfully"
"deleteError" = "Failed to delete node"
"updateSuccess" = "Node updated successfully"
"updateError" = "Failed to update node"
"addSuccess" = "Node added successfully"
"addError" = "Failed to add node"
[pages.nodes.toasts]
"createSuccess" = "Node created successfully"
"createError" = "Failed to create node"
"updateSuccess" = "Node updated successfully"
"updateError" = "Failed to update node"
"deleteSuccess" = "Node deleted successfully"
"deleteError" = "Failed to delete node"
"checkStatusSuccess" = "Node health check completed"
"checkStatusError" = "Failed to check node status"
"obtainError" = "Failed to get nodes"

View file

@ -28,6 +28,7 @@
"edit" = "Изменить"
"delete" = "Удалить"
"reset" = "Сбросить"
"refresh" = "Обновить"
"noData" = "Нет данных."
"copySuccess" = "Скопировано"
"sure" = "Да"
@ -118,6 +119,7 @@
"swap" = "Файл подкачки"
"storage" = "Диск"
"memory" = "ОЗУ"
"nodesAvailability" = "Доступность нод"
"threads" = "Потоки"
"xrayStatus" = "Xray"
"stopXray" = "Остановить"
@ -592,10 +594,18 @@
"checkAllNodes" = "Проверить все ноды"
"nodeName" = "Имя ноды"
"nodeAddress" = "Адрес ноды"
"nodePort" = "Порт"
"nodeApiKey" = "API ключ"
"nodeStatus" = "Статус"
"lastCheck" = "Последняя проверка"
"actions" = "Действия"
"operate" = "Действия"
"name" = "Имя"
"address" = "Адрес"
"status" = "Статус"
"assignedInbounds" = "Назначенные подключения"
"checkAll" = "Проверить все"
"check" = "Проверить"
"online" = "Онлайн"
"offline" = "Офлайн"
"error" = "Ошибка"
@ -603,17 +613,28 @@
"enterNodeName" = "Пожалуйста, введите имя ноды"
"enterNodeAddress" = "Пожалуйста, введите адрес ноды"
"validUrl" = "Должен быть действительным URL (http:// или https://)"
"validPort" = "Порт должен быть числом от 1 до 65535"
"duplicateNode" = "Нода с таким адресом и портом уже существует"
"fullUrlHint" = "Полный URL к API ноды (например, http://192.168.1.100:8080)"
"enterApiKey" = "Пожалуйста, введите API ключ"
"apiKeyHint" = "API ключ, настроенный на ноде (переменная окружения NODE_API_KEY)"
"leaveEmptyToKeep" = "оставьте пустым чтобы не менять"
"loadError" = "Не удалось загрузить список нод"
"checkSuccess" = "Проверка ноды завершена"
"checkError" = "Не удалось проверить ноду"
"checkingAll" = "Проверка всех нод..."
"deleteConfirm" = "Подтверждение удаления"
"deleteConfirmText" = "Вы уверены, что хотите удалить эту ноду?"
"deleteSuccess" = "Нода успешно удалена"
"deleteError" = "Не удалось удалить ноду"
"updateSuccess" = "Нода успешно обновлена"
"updateError" = "Не удалось обновить ноду"
"addSuccess" = "Нода успешно добавлена"
"addError" = "Не удалось добавить ноду"
[pages.nodes.toasts]
"createSuccess" = "Нода успешно создана"
"createError" = "Не удалось создать ноду"
"updateSuccess" = "Нода успешно обновлена"
"updateError" = "Не удалось обновить ноду"
"deleteSuccess" = "Нода успешно удалена"
"deleteError" = "Не удалось удалить ноду"
"checkStatusSuccess" = "Проверка здоровья ноды завершена"
"checkStatusError" = "Не удалось проверить статус ноды"
"obtainError" = "Не удалось получить список нод"

View file

@ -343,8 +343,10 @@ func (s *Server) startTask() {
s.cron.AddJob(runtime, j)
}
// Node health check job (every 5 minutes)
s.cron.AddJob("@every 5m", job.NewCheckNodeHealthJob())
// Node health check job (every 10 seconds)
s.cron.AddJob("@every 10s", job.NewCheckNodeHealthJob())
// Collect node statistics (traffic and online clients) every 30 seconds
s.cron.AddJob("@every 30s", job.NewCollectNodeStatsJob())
// Make a traffic condition every day, 8:30
var entry cron.EntryID