diff --git a/database/model/model.go b/database/model/model.go
index d300c6cf..f38a9acd 100644
--- a/database/model/model.go
+++ b/database/model/model.go
@@ -149,11 +149,17 @@ type Node struct {
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
+ PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
LastError string `json:"lastError"`
+ InboundCount int `json:"inboundCount" gorm:"-"`
+ ClientCount int `json:"clientCount" gorm:"-"`
+ OnlineCount int `json:"onlineCount" gorm:"-"`
+ DepletedCount int `json:"depletedCount" gorm:"-"`
+
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
}
diff --git a/frontend/src/pages/nodes/NodeList.vue b/frontend/src/pages/nodes/NodeList.vue
index 73dc6236..434aa80b 100644
--- a/frontend/src/pages/nodes/NodeList.vue
+++ b/frontend/src/pages/nodes/NodeList.vue
@@ -184,6 +184,10 @@ function isExpanded(id) {
{{ t('pages.nodes.xrayVersion') }}
{{ statsNode.xrayVersion || '-' }}
+
+
{{ t('pages.nodes.panelVersion') || 'Panel version' }}
+
{{ statsNode.panelVersion || '-' }}
+
{{ t('pages.nodes.uptime') }}
{{ formatUptime(statsNode.uptimeSecs) }}
@@ -195,6 +199,16 @@ function isExpanded(id) {
-
+
+
{{ t('clients') }}
+
{{ statsNode.clientCount || 0 }}
+
+ {{ statsNode.onlineCount }} {{ t('online') }}
+
+
+ {{ statsNode.depletedCount }} {{ t('depleted') }}
+
+
{{ t('pages.nodes.lastHeartbeat') }}
{{ relativeTime(statsNode.lastHeartbeat) }}
@@ -260,10 +274,30 @@ function isExpanded(id) {
+
+
+ {{ record.panelVersion || '-' }}
+
+
+
{{ formatUptime(record.uptimeSecs) }}
+
+
+
+ {{ record.clientCount || 0 }}
+
+ {{ record.onlineCount }} {{ t('online') }}
+
+
+ {{ record.depletedCount }} {{ t('depleted') }}
+
+
+
+
+
{{ record.latencyMs }} ms
diff --git a/frontend/src/pages/nodes/useNodes.js b/frontend/src/pages/nodes/useNodes.js
index cb4a6e8c..1282a94e 100644
--- a/frontend/src/pages/nodes/useNodes.js
+++ b/frontend/src/pages/nodes/useNodes.js
@@ -71,15 +71,21 @@ export function useNodes() {
return msg;
}
- // Aggregate cards on the dashboard. Computed off the live list so a
- // refresh (or a WS push) picks up new totals automatically.
const totals = computed(() => {
const list = nodes.value;
let online = 0;
let offline = 0;
let latencySum = 0;
let latencyCount = 0;
+ let inbounds = 0;
+ let clients = 0;
+ let onlineClients = 0;
+ let depleted = 0;
for (const n of list) {
+ inbounds += n.inboundCount || 0;
+ clients += n.clientCount || 0;
+ onlineClients += n.onlineCount || 0;
+ depleted += n.depletedCount || 0;
if (!n.enable) continue;
if (n.status === 'online') {
online += 1;
@@ -96,6 +102,10 @@ export function useNodes() {
online,
offline,
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
+ inbounds,
+ clients,
+ onlineClients,
+ depleted,
};
});
diff --git a/web/service/node.go b/web/service/node.go
index 1c834f78..8eb9395c 100644
--- a/web/service/node.go
+++ b/web/service/node.go
@@ -24,6 +24,7 @@ type HeartbeatPatch struct {
LastHeartbeat int64
LatencyMs int
XrayVersion string
+ PanelVersion string
CpuPct float64
MemPct float64
UptimeSecs uint64
@@ -45,7 +46,105 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
db := database.GetDB()
var nodes []*model.Node
err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
- return nodes, err
+ if err != nil || len(nodes) == 0 {
+ return nodes, err
+ }
+
+ type inboundRow struct {
+ Id int
+ NodeID int `gorm:"column:node_id"`
+ }
+ var inboundRows []inboundRow
+ if err := db.Table("inbounds").
+ Select("id, node_id").
+ Where("node_id IS NOT NULL").
+ Scan(&inboundRows).Error; err != nil {
+ return nodes, nil
+ }
+ if len(inboundRows) == 0 {
+ return nodes, nil
+ }
+ inboundsByNode := make(map[int][]int, len(nodes))
+ nodeByInbound := make(map[int]int, len(inboundRows))
+ for _, row := range inboundRows {
+ inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
+ nodeByInbound[row.Id] = row.NodeID
+ }
+
+ type clientCountRow struct {
+ NodeID int `gorm:"column:node_id"`
+ Count int `gorm:"column:count"`
+ }
+ var clientCounts []clientCountRow
+ if err := db.Raw(`
+ SELECT inbounds.node_id AS node_id, COUNT(DISTINCT client_inbounds.client_id) AS count
+ FROM inbounds
+ JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
+ WHERE inbounds.node_id IS NOT NULL
+ GROUP BY inbounds.node_id
+ `).Scan(&clientCounts).Error; err == nil {
+ for _, row := range clientCounts {
+ for _, n := range nodes {
+ if n.Id == row.NodeID {
+ n.ClientCount = row.Count
+ break
+ }
+ }
+ }
+ }
+
+ now := time.Now().UnixMilli()
+ type trafficRow struct {
+ InboundID int `gorm:"column:inbound_id"`
+ Email string
+ Enable bool
+ Total int64
+ Up int64
+ Down int64
+ ExpiryTime int64 `gorm:"column:expiry_time"`
+ }
+ var trafficRows []trafficRow
+ inboundIDs := make([]int, 0, len(nodeByInbound))
+ for id := range nodeByInbound {
+ inboundIDs = append(inboundIDs, id)
+ }
+ if err := db.Table("client_traffics").
+ Select("inbound_id, email, enable, total, up, down, expiry_time").
+ Where("inbound_id IN ?", inboundIDs).
+ Scan(&trafficRows).Error; err == nil {
+ online := make(map[string]struct{})
+ for _, email := range s.onlineEmails() {
+ online[email] = struct{}{}
+ }
+ depletedByNode := make(map[int]int)
+ onlineByNode := make(map[int]int)
+ for _, row := range trafficRows {
+ nodeID, ok := nodeByInbound[row.InboundID]
+ if !ok {
+ continue
+ }
+ expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
+ exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
+ if expired || exhausted || !row.Enable {
+ depletedByNode[nodeID]++
+ }
+ if _, ok := online[row.Email]; ok {
+ onlineByNode[nodeID]++
+ }
+ }
+ for _, n := range nodes {
+ n.InboundCount = len(inboundsByNode[n.Id])
+ n.DepletedCount = depletedByNode[n.Id]
+ n.OnlineCount = onlineByNode[n.Id]
+ }
+ }
+
+ return nodes, nil
+}
+
+func (s *NodeService) onlineEmails() []string {
+ svc := InboundService{}
+ return svc.GetOnlineClients()
}
func (s *NodeService) GetById(id int) (*model.Node, error) {
@@ -154,6 +253,7 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
"last_heartbeat": p.LastHeartbeat,
"latency_ms": p.LatencyMs,
"xray_version": p.XrayVersion,
+ "panel_version": p.PanelVersion,
"cpu_pct": p.CpuPct,
"mem_pct": p.MemPct,
"uptime_secs": p.UptimeSecs,
@@ -238,7 +338,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
Xray struct {
Version string `json:"version"`
} `json:"xray"`
- Uptime uint64 `json:"uptime"`
+ PanelVersion string `json:"panelVersion"`
+ Uptime uint64 `json:"uptime"`
} `json:"obj"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
@@ -255,28 +356,31 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
}
patch.XrayVersion = o.Xray.Version
+ patch.PanelVersion = o.PanelVersion
patch.UptimeSecs = o.Uptime
return patch, nil
}
type ProbeResultUI struct {
- Status string `json:"status"`
- LatencyMs int `json:"latencyMs"`
- XrayVersion string `json:"xrayVersion"`
- CpuPct float64 `json:"cpuPct"`
- MemPct float64 `json:"memPct"`
- UptimeSecs uint64 `json:"uptimeSecs"`
- Error string `json:"error"`
+ Status string `json:"status"`
+ LatencyMs int `json:"latencyMs"`
+ XrayVersion string `json:"xrayVersion"`
+ PanelVersion string `json:"panelVersion"`
+ CpuPct float64 `json:"cpuPct"`
+ MemPct float64 `json:"memPct"`
+ UptimeSecs uint64 `json:"uptimeSecs"`
+ Error string `json:"error"`
}
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
r := ProbeResultUI{
- LatencyMs: p.LatencyMs,
- XrayVersion: p.XrayVersion,
- CpuPct: p.CpuPct,
- MemPct: p.MemPct,
- UptimeSecs: p.UptimeSecs,
- Error: p.LastError,
+ LatencyMs: p.LatencyMs,
+ XrayVersion: p.XrayVersion,
+ PanelVersion: p.PanelVersion,
+ CpuPct: p.CpuPct,
+ MemPct: p.MemPct,
+ UptimeSecs: p.UptimeSecs,
+ Error: p.LastError,
}
if ok {
r.Status = "online"
diff --git a/web/service/server.go b/web/service/server.go
index 54aef910..8bde4eae 100644
--- a/web/service/server.go
+++ b/web/service/server.go
@@ -71,6 +71,7 @@ type Status struct {
ErrorMsg string `json:"errorMsg"`
Version string `json:"version"`
} `json:"xray"`
+ PanelVersion string `json:"panelVersion"`
Uptime uint64 `json:"uptime"`
Loads []float64 `json:"loads"`
TcpCount int `json:"tcpCount"`
@@ -360,6 +361,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
}
status.Xray.Version = s.xrayService.GetXrayVersion()
+ status.PanelVersion = config.GetVersion()
// Application stats
var rtm runtime.MemStats
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index 1be7bd06..d969b647 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -484,6 +484,7 @@
"latency": "Latency",
"lastHeartbeat": "Last Heartbeat",
"xrayVersion": "Xray Version",
+ "panelVersion": "Panel Version",
"actions": "Actions",
"probe": "Probe Now",
"testConnection": "Test Connection",
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index 5c57b685..c6eb8100 100644
--- a/web/translation/fa-IR.json
+++ b/web/translation/fa-IR.json
@@ -426,6 +426,7 @@
"latency": "تاخیر",
"lastHeartbeat": "آخرین ضربان",
"xrayVersion": "نسخه Xray",
+ "panelVersion": "نسخه پنل",
"actions": "عملیات",
"probe": "بررسی فوری",
"testConnection": "تست اتصال",