feat: 管理后台仪表盘统计卡片接入真实数据

新增 /api/admin/stats 聚合接口,填充运行时长、活跃设备、今日流量、在线节点四项卡片:
- VpnService 记录启动时间,提供 StartedAt/TotalLiveTraffic 方法
- 新增 TrafficStat 模型,客户端断开时 upsert 当日 rx/tx 流量(持久化)
- 前端 AdminView 调用 stats 接口并每 30 秒自动刷新
This commit is contained in:
2026-07-07 15:43:29 +08:00
parent 570dc82125
commit c14fe5ec06
7 changed files with 160 additions and 27 deletions
+32 -13
View File
@@ -6,6 +6,7 @@ import (
"log"
"net"
"sync"
"time"
"lmvpn/internal/model"
@@ -13,21 +14,22 @@ import (
)
type VpnService struct {
mu sync.RWMutex
settings model.VpnSetting
net *net.IPNet
serverIP net.IP
prefix int
alloc *AllocationManager
net6 *net.IPNet
mu sync.RWMutex
settings model.VpnSetting
net *net.IPNet
serverIP net.IP
prefix int
alloc *AllocationManager
net6 *net.IPNet
serverIP6 net.IP
prefix6 int
alloc6 *AllocationManager
switchx *PacketSwitch
tun *TUNInterface
tunDone chan struct{}
running bool
clients map[*tunnelConn]struct{}
alloc6 *AllocationManager
switchx *PacketSwitch
tun *TUNInterface
tunDone chan struct{}
running bool
startedAt time.Time
clients map[*tunnelConn]struct{}
}
func NewVpnService() *VpnService {
@@ -42,6 +44,22 @@ func (s *VpnService) Running() bool {
return s.running
}
func (s *VpnService) StartedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
return s.startedAt
}
func (s *VpnService) TotalLiveTraffic() (rx, tx int64) {
s.mu.RLock()
for c := range s.clients {
rx += c.rxBytes.Load()
tx += c.txBytes.Load()
}
s.mu.RUnlock()
return
}
func (s *VpnService) Settings() model.VpnSetting {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -133,6 +151,7 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
s.tun = tun
s.tunDone = make(chan struct{})
s.running = true
s.startedAt = time.Now()
s.mu.Unlock()
go s.serveTUN()
+35 -12
View File
@@ -8,9 +8,12 @@ import (
"sync/atomic"
"time"
"lmvpn/internal/db"
"lmvpn/internal/model"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
@@ -28,19 +31,19 @@ var (
)
type tunnelConn struct {
conn *websocket.Conn
user *model.User
svc *VpnService
assignedIP net.IP
assignedIP6 net.IP
connectedAt time.Time
writeMu sync.Mutex
ready atomic.Bool
rxBytes atomic.Int64
txBytes atomic.Int64
conn *websocket.Conn
user *model.User
svc *VpnService
assignedIP net.IP
assignedIP6 net.IP
connectedAt time.Time
writeMu sync.Mutex
ready atomic.Bool
rxBytes atomic.Int64
txBytes atomic.Int64
}
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 }
func (c *tunnelConn) WritePacket(data []byte) error {
@@ -128,7 +131,10 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
}
VPN.registerClient(tc)
defer VPN.unregisterClient(tc)
defer func() {
recordTraffic(tc.rxBytes.Load(), tc.txBytes.Load())
VPN.unregisterClient(tc)
}()
settings := VPN.Settings()
initMsg := initMessage{
@@ -222,3 +228,20 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
}
}
}
func recordTraffic(rx, tx int64) {
if rx == 0 && tx == 0 {
return
}
today := time.Now().Format("2006-01-02")
stat := model.TrafficStat{Date: today, RxBytes: rx, TxBytes: tx}
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"rx_bytes": gorm.Expr("rx_bytes + ?", rx),
"tx_bytes": gorm.Expr("tx_bytes + ?", tx),
}),
}).Create(&stat).Error; err != nil {
log.Printf("记录流量失败: %v", err)
}
}