feat: 管理后台仪表盘统计卡片接入真实数据
新增 /api/admin/stats 聚合接口,填充运行时长、活跃设备、今日流量、在线节点四项卡片: - VpnService 记录启动时间,提供 StartedAt/TotalLiveTraffic 方法 - 新增 TrafficStat 模型,客户端断开时 upsert 当日 rx/tx 流量(持久化) - 前端 AdminView 调用 stats 接口并每 30 秒自动刷新
This commit is contained in:
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+32
-13
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lmvpn/internal/model"
|
"lmvpn/internal/model"
|
||||||
|
|
||||||
@@ -13,21 +14,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type VpnService struct {
|
type VpnService struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
settings model.VpnSetting
|
settings model.VpnSetting
|
||||||
net *net.IPNet
|
net *net.IPNet
|
||||||
serverIP net.IP
|
serverIP net.IP
|
||||||
prefix int
|
prefix int
|
||||||
alloc *AllocationManager
|
alloc *AllocationManager
|
||||||
net6 *net.IPNet
|
net6 *net.IPNet
|
||||||
serverIP6 net.IP
|
serverIP6 net.IP
|
||||||
prefix6 int
|
prefix6 int
|
||||||
alloc6 *AllocationManager
|
alloc6 *AllocationManager
|
||||||
switchx *PacketSwitch
|
switchx *PacketSwitch
|
||||||
tun *TUNInterface
|
tun *TUNInterface
|
||||||
tunDone chan struct{}
|
tunDone chan struct{}
|
||||||
running bool
|
running bool
|
||||||
clients map[*tunnelConn]struct{}
|
startedAt time.Time
|
||||||
|
clients map[*tunnelConn]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVpnService() *VpnService {
|
func NewVpnService() *VpnService {
|
||||||
@@ -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()
|
||||||
|
|||||||
+35
-12
@@ -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 (
|
||||||
@@ -28,19 +31,19 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type tunnelConn struct {
|
type tunnelConn struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
user *model.User
|
user *model.User
|
||||||
svc *VpnService
|
svc *VpnService
|
||||||
assignedIP net.IP
|
assignedIP net.IP
|
||||||
assignedIP6 net.IP
|
assignedIP6 net.IP
|
||||||
connectedAt time.Time
|
connectedAt time.Time
|
||||||
writeMu sync.Mutex
|
writeMu sync.Mutex
|
||||||
ready atomic.Bool
|
ready atomic.Bool
|
||||||
rxBytes atomic.Int64
|
rxBytes atomic.Int64
|
||||||
txBytes 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) AssignedIP6() net.IP { return c.assignedIP6 }
|
||||||
|
|
||||||
func (c *tunnelConn) WritePacket(data []byte) error {
|
func (c *tunnelConn) WritePacket(data []byte) error {
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user