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
+36 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -17,6 +17,17 @@ const stats = ref([
]) ])
const userCount = ref<number | null>(null) const userCount = ref<number | null>(null)
let statsTimer: ReturnType<typeof setInterval> | null = null
function formatUptime(seconds: number): string {
if (seconds <= 0) return '0m'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
async function fetchUserCount() { async function fetchUserCount() {
try { try {
@@ -32,9 +43,33 @@ async function fetchUserCount() {
} catch {} } catch {}
} }
async function fetchStats() {
try {
const res = await fetch('/api/admin/stats', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
const set = (label: string, value: string) => {
const stat = stats.value.find(s => s.label === label)
if (stat) stat.value = value
}
set('admin.uptime', formatUptime(data.uptime_seconds))
set('admin.activeDevices', String(data.active_devices))
set('admin.todayTraffic', (data.today_traffic_bytes / 1e9).toFixed(2))
set('admin.onlineNodes', String(data.online_nodes))
} catch {}
}
onMounted(async () => { onMounted(async () => {
await authStore.fetchUser() await authStore.fetchUser()
fetchUserCount() fetchUserCount()
fetchStats()
statsTimer = setInterval(fetchStats, 30000)
})
onUnmounted(() => {
if (statsTimer) clearInterval(statsTimer)
}) })
function handleStatClick(route: string) { function handleStatClick(route: string) {
+1 -1
View File
@@ -40,7 +40,7 @@ func Init(cfg *config.DatabaseConfig) error {
return fmt.Errorf("数据库连接失败: %w", err) return fmt.Errorf("数据库连接失败: %w", err)
} }
if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}); err != nil { if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}); err != nil {
return fmt.Errorf("数据库迁移失败: %w", err) return fmt.Errorf("数据库迁移失败: %w", err)
} }
+43
View File
@@ -0,0 +1,43 @@
package handler
import (
"net/http"
"time"
"lmvpn/internal/db"
"lmvpn/internal/model"
"lmvpn/internal/vpn"
"github.com/gin-gonic/gin"
)
type adminStatsResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"`
ActiveDevices int `json:"active_devices"`
TodayTrafficBytes int64 `json:"today_traffic_bytes"`
OnlineNodes int `json:"online_nodes"`
}
func GetAdminStats(c *gin.Context) {
var uptime int64
var onlineNodes int
if vpn.VPN.Running() {
uptime = int64(time.Since(vpn.VPN.StartedAt()).Seconds())
onlineNodes = 1
}
activeDevices := len(vpn.VPN.ClientList())
today := time.Now().Format("2006-01-02")
var stat model.TrafficStat
db.DB.Where("date = ?", today).First(&stat)
liveRx, liveTx := vpn.VPN.TotalLiveTraffic()
todayTraffic := stat.RxBytes + stat.TxBytes + liveRx + liveTx
c.JSON(http.StatusOK, adminStatsResponse{
UptimeSeconds: uptime,
ActiveDevices: activeDevices,
TodayTrafficBytes: todayTraffic,
OnlineNodes: onlineNodes,
})
}
+12
View File
@@ -32,3 +32,15 @@ type VpnReservation struct {
func (VpnReservation) TableName() string { func (VpnReservation) TableName() string {
return "vpn_reservations" return "vpn_reservations"
} }
type TrafficStat struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Date string `gorm:"uniqueIndex;size:10;not null"`
RxBytes int64 `gorm:"default:0"`
TxBytes int64 `gorm:"default:0"`
UpdatedAt time.Time
}
func (TrafficStat) TableName() string {
return "traffic_stats"
}
+1
View File
@@ -28,6 +28,7 @@ func Setup(r *gin.Engine) {
admin := r.Group("/api/admin") admin := r.Group("/api/admin")
admin.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware()) admin.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware())
{ {
admin.GET("/stats", handler.GetAdminStats)
admin.GET("/users/count", handler.GetUserCount) admin.GET("/users/count", handler.GetUserCount)
admin.GET("/users", handler.ListUsers) admin.GET("/users", handler.ListUsers)
admin.POST("/users", handler.CreateUser) admin.POST("/users", handler.CreateUser)
+19
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"net" "net"
"sync" "sync"
"time"
"lmvpn/internal/model" "lmvpn/internal/model"
@@ -27,6 +28,7 @@ type VpnService struct {
tun *TUNInterface tun *TUNInterface
tunDone chan struct{} tunDone chan struct{}
running bool running bool
startedAt time.Time
clients map[*tunnelConn]struct{} clients map[*tunnelConn]struct{}
} }
@@ -42,6 +44,22 @@ func (s *VpnService) Running() bool {
return s.running 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 { func (s *VpnService) Settings() model.VpnSetting {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -133,6 +151,7 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
s.tun = tun s.tun = tun
s.tunDone = make(chan struct{}) s.tunDone = make(chan struct{})
s.running = true s.running = true
s.startedAt = time.Now()
s.mu.Unlock() s.mu.Unlock()
go s.serveTUN() go s.serveTUN()
+24 -1
View File
@@ -8,9 +8,12 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"lmvpn/internal/db"
"lmvpn/internal/model" "lmvpn/internal/model"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
const ( const (
@@ -128,7 +131,10 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
} }
VPN.registerClient(tc) VPN.registerClient(tc)
defer VPN.unregisterClient(tc) defer func() {
recordTraffic(tc.rxBytes.Load(), tc.txBytes.Load())
VPN.unregisterClient(tc)
}()
settings := VPN.Settings() settings := VPN.Settings()
initMsg := initMessage{ 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)
}
}