mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
750bd93681
commit
17433c39f4
7 changed files with 175 additions and 17 deletions
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -484,6 +484,7 @@
|
|||
"latency": "Latency",
|
||||
"lastHeartbeat": "Last Heartbeat",
|
||||
"xrayVersion": "Xray Version",
|
||||
"panelVersion": "Panel Version",
|
||||
"actions": "Actions",
|
||||
"probe": "Probe Now",
|
||||
"testConnection": "Test Connection",
|
||||
|
|
|
|||
|
|
@ -426,6 +426,7 @@
|
|||
"latency": "تاخیر",
|
||||
"lastHeartbeat": "آخرین ضربان",
|
||||
"xrayVersion": "نسخه Xray",
|
||||
"panelVersion": "نسخه پنل",
|
||||
"actions": "عملیات",
|
||||
"probe": "بررسی فوری",
|
||||
"testConnection": "تست اتصال",
|
||||
|
|
|
|||
Loading…
Reference in a new issue