后台:修复直播开播页空白(补全 watch 导入);控制台展示应用带宽观测与 HTTP 流量统计
Made-with: Cursor
This commit is contained in:
@@ -27,6 +27,61 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<el-card v-if="bandwidth" class="bw-card" style="margin-top: 20px">
|
||||||
|
<template #header>
|
||||||
|
<div class="bw-header">
|
||||||
|
<span>应用带宽观测</span>
|
||||||
|
<el-text type="info" size="small">{{ bwUpdatedAt }}</el-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-alert type="info" :closable="false" show-icon class="bw-tip">
|
||||||
|
以下为<strong>本 Go 进程</strong>统计的 HTTP 请求/响应字节量,用于粗估负载;若前面还有 Nginx/CDN,<strong>公网出口带宽</strong>可能更高。WebSocket(如直播信令)升级后的流量可能未完全计入。
|
||||||
|
</el-alert>
|
||||||
|
<el-row :gutter="16" class="bw-row">
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<div class="bw-metric">
|
||||||
|
<div class="bw-label">出站累计(用户下载为主)</div>
|
||||||
|
<div class="bw-value">{{ formatBytes(bandwidth.bytes_out_total) }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<div class="bw-metric">
|
||||||
|
<div class="bw-label">入站累计(上传/POST)</div>
|
||||||
|
<div class="bw-value">{{ formatBytes(bandwidth.bytes_in_total) }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<div class="bw-metric">
|
||||||
|
<div class="bw-label">近 60 秒出站 · 约 Mbps</div>
|
||||||
|
<div class="bw-value accent">{{ bandwidth.recent_egress_mbps }}</div>
|
||||||
|
<div class="bw-sub">{{ formatBytes(bandwidth.bytes_out_last_60s) }} / 60s</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<div class="bw-metric">
|
||||||
|
<div class="bw-label">自启动平均出站 · Mbps</div>
|
||||||
|
<div class="bw-value accent">{{ bandwidth.avg_egress_mbps }}</div>
|
||||||
|
<div class="bw-sub">运行 {{ formatUptime(bandwidth.uptime_seconds) }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16" class="bw-row bw-row--second">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<div class="bw-metric bw-metric--inline">
|
||||||
|
<span class="bw-label">近 60 秒入站约 Mbps</span>
|
||||||
|
<span class="bw-value-inline">{{ bandwidth.recent_ingress_mbps }}</span>
|
||||||
|
<span class="bw-sub">{{ formatBytes(bandwidth.bytes_in_last_60s) }} / 60s</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<div class="bw-metric bw-metric--inline">
|
||||||
|
<span class="bw-label">自启动平均入站 · Mbps</span>
|
||||||
|
<span class="bw-value-inline">{{ bandwidth.avg_ingress_mbps }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card style="margin-top: 20px">
|
<el-card style="margin-top: 20px">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>快捷入口</span>
|
<span>快捷入口</span>
|
||||||
@@ -41,7 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { getStats } from '../api/admin'
|
import { getStats } from '../api/admin'
|
||||||
|
|
||||||
const stats = reactive({
|
const stats = reactive({
|
||||||
@@ -52,13 +107,51 @@ const stats = reactive({
|
|||||||
files: 0
|
files: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bandwidth = ref(null)
|
||||||
|
const bwFetchAt = ref(0)
|
||||||
|
|
||||||
|
const bwUpdatedAt = computed(() => {
|
||||||
|
if (!bwFetchAt.value) return ''
|
||||||
|
const d = new Date(bwFetchAt.value)
|
||||||
|
return `更新于 ${d.toLocaleTimeString()}`
|
||||||
|
})
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
function formatBytes(n) {
|
||||||
|
if (n == null || !Number.isFinite(n) || n < 0) return '—'
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
const u = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let v = n
|
||||||
|
let i = -1
|
||||||
|
do {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
} while (v >= 1024 && i < u.length - 1)
|
||||||
|
return `${v < 10 ? v.toFixed(2) : v.toFixed(1)} ${u[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(sec) {
|
||||||
|
if (sec == null || !Number.isFinite(sec) || sec < 0) return '—'
|
||||||
|
const s = Math.floor(sec)
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
const r = s % 60
|
||||||
|
if (h > 0) return `${h} 小时 ${m} 分`
|
||||||
|
if (m > 0) return `${m} 分 ${r} 秒`
|
||||||
|
return `${r} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getStats()
|
const res = await getStats()
|
||||||
Object.assign(stats, res)
|
const { bandwidth: bw, ...rest } = res
|
||||||
|
Object.assign(stats, rest)
|
||||||
|
if (bw && typeof bw === 'object') {
|
||||||
|
bandwidth.value = bw
|
||||||
|
bwFetchAt.value = Date.now()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取统计失败:', e)
|
console.error('获取统计失败:', e)
|
||||||
// 即使失败也显示 0,不阻塞页面
|
// 即使失败也显示 0,不阻塞页面
|
||||||
@@ -67,7 +160,30 @@ const fetchStats = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchStats)
|
let pollTimer = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStats()
|
||||||
|
pollTimer = window.setInterval(() => {
|
||||||
|
getStats()
|
||||||
|
.then((res) => {
|
||||||
|
const { bandwidth: bw, ...rest } = res
|
||||||
|
Object.assign(stats, rest)
|
||||||
|
if (bw && typeof bw === 'object') {
|
||||||
|
bandwidth.value = bw
|
||||||
|
bwFetchAt.value = Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, 8000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer != null) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -85,4 +201,58 @@ onMounted(fetchStats)
|
|||||||
color: #666;
|
color: #666;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.bw-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.bw-tip {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.bw-row {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.bw-row--second {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.bw-metric {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
.bw-metric--inline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px 12px;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.bw-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.bw-metric--inline .bw-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.bw-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.bw-value.accent {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.bw-value-inline {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.bw-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { onBeforeRouteLeave } from 'vue-router'
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { startPublishing } from '../../utils/liveWebRTC'
|
import { startPublishing } from '../../utils/liveWebRTC'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"yh_web/server/config"
|
"yh_web/server/config"
|
||||||
|
"yh_web/server/pkg/traffic"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -30,5 +31,6 @@ func GetStats(c *gin.Context) {
|
|||||||
"conversations": conversations,
|
"conversations": conversations,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"files": files,
|
"files": files,
|
||||||
|
"bandwidth": traffic.Snapshot(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func main() {
|
|||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制
|
r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制
|
||||||
r.Use(middleware.ErrorLogger())
|
r.Use(middleware.ErrorLogger())
|
||||||
|
r.Use(middleware.TrafficMeter())
|
||||||
|
|
||||||
// CORS(ALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
|
// CORS(ALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
|
||||||
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
|
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
|
||||||
|
|||||||
53
server/middleware/traffic_meter.go
Normal file
53
server/middleware/traffic_meter.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"yh_web/server/pkg/traffic"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type countReadCloser struct {
|
||||||
|
io.ReadCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countReadCloser) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.ReadCloser.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
traffic.AddIn(n)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type meterResponseWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *meterResponseWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := w.ResponseWriter.Write(p)
|
||||||
|
if n > 0 {
|
||||||
|
traffic.AddOut(n)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *meterResponseWriter) WriteString(s string) (int, error) {
|
||||||
|
n, err := w.ResponseWriter.WriteString(s)
|
||||||
|
if n > 0 {
|
||||||
|
traffic.AddOut(n)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrafficMeter 统计 HTTP 请求体与响应体字节量(进程级,非网卡级)。
|
||||||
|
func TrafficMeter() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if c.Request.Body != nil && c.Request.Body != http.NoBody {
|
||||||
|
c.Request.Body = &countReadCloser{ReadCloser: c.Request.Body}
|
||||||
|
}
|
||||||
|
c.Writer = &meterResponseWriter{ResponseWriter: c.Writer}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
108
server/pkg/traffic/meter.go
Normal file
108
server/pkg/traffic/meter.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Package traffic 统计经过本进程的 HTTP 流量(请求体 + 响应体),供后台评估带宽。
|
||||||
|
// 说明:前有 Nginx 时边缘出口可能更大;WebSocket 升级后部分流量可能不经此计数。
|
||||||
|
package traffic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
totalIn atomic.Uint64
|
||||||
|
totalOut atomic.Uint64
|
||||||
|
started = time.Now()
|
||||||
|
|
||||||
|
tickerOnce sync.Once
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
secIn [60]uint64
|
||||||
|
secOut [60]uint64
|
||||||
|
lastSnapIn uint64
|
||||||
|
lastSnapOut uint64
|
||||||
|
tickIndex int64
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureTicker() {
|
||||||
|
tickerOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func tick() {
|
||||||
|
ti := totalIn.Load()
|
||||||
|
to := totalOut.Load()
|
||||||
|
mu.Lock()
|
||||||
|
i := int(tickIndex % 60)
|
||||||
|
secIn[i] = ti - lastSnapIn
|
||||||
|
secOut[i] = to - lastSnapOut
|
||||||
|
lastSnapIn = ti
|
||||||
|
lastSnapOut = to
|
||||||
|
tickIndex++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddIn 记录请求体已读字节。
|
||||||
|
func AddIn(n int) {
|
||||||
|
if n > 0 {
|
||||||
|
ensureTicker()
|
||||||
|
totalIn.Add(uint64(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOut 记录响应已写字节。
|
||||||
|
func AddOut(n int) {
|
||||||
|
if n > 0 {
|
||||||
|
ensureTicker()
|
||||||
|
totalOut.Add(uint64(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot 返回当前统计(近 60 秒为滚动窗口内各秒增量之和)。
|
||||||
|
func Snapshot() map[string]any {
|
||||||
|
ensureTicker()
|
||||||
|
tin := totalIn.Load()
|
||||||
|
tout := totalOut.Load()
|
||||||
|
up := time.Since(started).Seconds()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
var sumIn, sumOut uint64
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
sumIn += secIn[i]
|
||||||
|
sumOut += secOut[i]
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
var avgDown, avgUp, recentDown, recentUp float64
|
||||||
|
if up > 0.5 {
|
||||||
|
avgDown = float64(tout) * 8 / (up * 1e6) // Mbps 出站(自启动平均)
|
||||||
|
avgUp = float64(tin) * 8 / (up * 1e6) // Mbps 入站
|
||||||
|
}
|
||||||
|
recentDown = float64(sumOut) * 8 / (60 * 1e6)
|
||||||
|
recentUp = float64(sumIn) * 8 / (60 * 1e6)
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"bytes_in_total": tin,
|
||||||
|
"bytes_out_total": tout,
|
||||||
|
"bytes_in_last_60s": sumIn,
|
||||||
|
"bytes_out_last_60s": sumOut,
|
||||||
|
"uptime_seconds": up,
|
||||||
|
"avg_egress_mbps": round2(avgDown),
|
||||||
|
"avg_ingress_mbps": round2(avgUp),
|
||||||
|
"recent_egress_mbps": round2(recentDown),
|
||||||
|
"recent_ingress_mbps": round2(recentUp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func round2(x float64) float64 {
|
||||||
|
if x < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(int64(x*100+0.5)) / 100
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user