feat(nodes): per-node client roll-up and panel version

Added transient inboundCount / clientCount / onlineCount /
depletedCount fields to model.Node, populated by NodeService.GetAll
via aggregated queries (one join across inbounds + client_inbounds,
one over client_traffics intersected with the in-memory online
emails). The Nodes list renders these as colored chips on a new
"Clients" column so an operator can see at a glance how many users
each node carries and how many are currently online or depleted.

Also exposes the remote panel's version. The central panel adds
panelVersion to its /api/server/status payload (sourced from
config.GetVersion). Probe reads that field and persists it on the
node row, mirroring how xrayVersion already flows. NodesPage gets
a new column next to Xray Version, in both desktop and mobile
views, with English and Persian strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-17 13:59:40 +02:00
parent 750bd93681
commit 17433c39f4
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
7 changed files with 175 additions and 17 deletions

View file

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

View file

@ -184,6 +184,10 @@ function isExpanded(id) {
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.panelVersion') || 'Panel version' }}</span>
<a-tag>{{ statsNode.panelVersion || '-' }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
@ -195,6 +199,16 @@ function isExpanded(id) {
<template v-else>-</template>
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('clients') }}</span>
<a-tag color="green">{{ statsNode.clientCount || 0 }}</a-tag>
<a-tag v-if="statsNode.onlineCount" color="blue">
{{ statsNode.onlineCount }} {{ t('online') }}
</a-tag>
<a-tag v-if="statsNode.depletedCount" color="red">
{{ statsNode.depletedCount }} {{ t('depleted') }}
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
@ -260,10 +274,30 @@ function isExpanded(id) {
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.panelVersion') || 'Panel version'" data-index="panelVersion" align="center">
<template #default="{ record }">
{{ record.panelVersion || '-' }}
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
<template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
</a-table-column>
<a-table-column :title="t('clients')" align="center" :width="160">
<template #default="{ record }">
<a-space :size="4">
<a-tag color="green">{{ record.clientCount || 0 }}</a-tag>
<a-tag v-if="record.onlineCount" color="blue">
{{ record.onlineCount }} {{ t('online') }}
</a-tag>
<a-tag v-if="record.depletedCount" color="red">
{{ record.depletedCount }} {{ t('depleted') }}
</a-tag>
</a-space>
</template>
</a-table-column>
<a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
<template #default="{ record }">
<span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>

View file

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

View file

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

View file

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

View file

@ -484,6 +484,7 @@
"latency": "Latency",
"lastHeartbeat": "Last Heartbeat",
"xrayVersion": "Xray Version",
"panelVersion": "Panel Version",
"actions": "Actions",
"probe": "Probe Now",
"testConnection": "Test Connection",

View file

@ -426,6 +426,7 @@
"latency": "تاخیر",
"lastHeartbeat": "آخرین ضربان",
"xrayVersion": "نسخه Xray",
"panelVersion": "نسخه پنل",
"actions": "عملیات",
"probe": "بررسی فوری",
"testConnection": "تست اتصال",