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
d7175e7803
commit
cdd90de099
15 changed files with 991 additions and 212 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
</div>
|
||||
<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='{{ 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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="nodes-list">
|
||||
<a-spin tip="Loading..."></a-spin>
|
||||
</div>
|
||||
</a-card>
|
||||
<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" }}');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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 loadNodes() {
|
||||
const listDiv = document.getElementById('nodes-list');
|
||||
listDiv.innerHTML = '<a-spin tip="Loading..."></a-spin>';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function addNode() {
|
||||
const name = document.getElementById('node-name').value;
|
||||
const address = document.getElementById('node-address').value;
|
||||
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 || !apiKey) {
|
||||
alert('Please fill all fields');
|
||||
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/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');
|
||||
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 = '';
|
||||
loadNodes();
|
||||
app.loadNodes();
|
||||
} else {
|
||||
alert('Error: ' + (data.msg || 'Failed to add 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" }}');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (newName === null) return;
|
||||
|
||||
const newAddress = prompt('Enter new address:', address);
|
||||
if (newAddress === null) return;
|
||||
|
||||
const apiKey = prompt('Enter API Key (leave empty to keep current):');
|
||||
|
||||
const data = { name: newName, address: newAddress };
|
||||
if (apiKey) {
|
||||
data.apiKey = apiKey;
|
||||
}
|
||||
|
||||
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();
|
||||
} else {
|
||||
alert('Error: ' + (result.msg || 'Failed to update node'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load nodes on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadNodes);
|
||||
} else {
|
||||
loadNodes();
|
||||
}
|
||||
</script>
|
||||
{{template "page/body_end" .}}
|
||||
|
|
|
|||
31
web/job/collect_node_stats_job.go
Normal file
31
web/job/collect_node_stats_job.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" = "Не удалось получить список нод"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue