feat: 网站统计优化

This commit is contained in:
耗子
2026-02-20 04:29:40 +08:00
parent b9cb492847
commit b39785fc34
9 changed files with 581 additions and 153 deletions
+50 -29
View File
@@ -4,19 +4,26 @@ import "time"
// WebsiteStat 网站统计(每站每天一行或每小时一行)
type WebsiteStat struct {
ID uint `gorm:"primaryKey" json:"id"`
Site string `gorm:"not null;uniqueIndex:idx_wstat_unique" json:"site"`
Date string `gorm:"not null;uniqueIndex:idx_wstat_unique;index" json:"date"`
Hour int `gorm:"not null;uniqueIndex:idx_wstat_unique;default:-1" json:"hour"` // -1=每日汇总, 0-23=小时
PV uint64 `gorm:"not null;default:0" json:"pv"`
UV uint64 `gorm:"not null;default:0" json:"uv"`
IP uint64 `gorm:"not null;default:0" json:"ip"`
Bandwidth uint64 `gorm:"not null;default:0" json:"bandwidth"`
Requests uint64 `gorm:"not null;default:0" json:"requests"`
Errors uint64 `gorm:"not null;default:0" json:"errors"`
Spiders uint64 `gorm:"not null;default:0" json:"spiders"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
Site string `gorm:"not null;uniqueIndex:idx_wstat_unique" json:"site"`
Date string `gorm:"not null;uniqueIndex:idx_wstat_unique;index" json:"date"`
Hour int `gorm:"not null;uniqueIndex:idx_wstat_unique;default:-1" json:"hour"` // -1=每日汇总, 0-23=小时
PV uint64 `gorm:"not null;default:0" json:"pv"`
UV uint64 `gorm:"not null;default:0" json:"uv"`
IP uint64 `gorm:"not null;default:0" json:"ip"`
Bandwidth uint64 `gorm:"not null;default:0" json:"bandwidth"`
BandwidthIn uint64 `gorm:"not null;default:0" json:"bandwidth_in"`
Requests uint64 `gorm:"not null;default:0" json:"requests"`
Errors uint64 `gorm:"not null;default:0" json:"errors"`
Spiders uint64 `gorm:"not null;default:0" json:"spiders"`
RequestTimeSum uint64 `gorm:"not null;default:0" json:"request_time_sum"`
RequestTimeCount uint64 `gorm:"not null;default:0" json:"request_time_count"`
Status2xx uint64 `gorm:"not null;default:0" json:"status_2xx"`
Status3xx uint64 `gorm:"not null;default:0" json:"status_3xx"`
Status4xx uint64 `gorm:"not null;default:0" json:"status_4xx"`
Status5xx uint64 `gorm:"not null;default:0" json:"status_5xx"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WebsiteErrorLog 网站错误日志(400-599 状态码详情)
@@ -86,14 +93,21 @@ type WebsiteStatURI struct {
// WebsiteStatSeries 时间序列数据点(用于 API 返回)
type WebsiteStatSeries struct {
Key string `json:"key"` // 小时 "0"-"23" 或日期 "2026-02-18"
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
Key string `json:"key"` // 小时 "0"-"23" 或日期 "2026-02-18"
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
BandwidthIn uint64 `json:"bandwidth_in"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
RequestTimeSum uint64 `json:"request_time_sum"`
RequestTimeCount uint64 `json:"request_time_count"`
Status2xx uint64 `json:"status_2xx"`
Status3xx uint64 `json:"status_3xx"`
Status4xx uint64 `json:"status_4xx"`
Status5xx uint64 `json:"status_5xx"`
}
// WebsiteStatSpiderRank 蜘蛛排名
@@ -140,14 +154,21 @@ type WebsiteStatURIRank struct {
// WebsiteStatSiteItem 网站维度汇总
type WebsiteStatSiteItem struct {
Site string `json:"site"`
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
Site string `json:"site"`
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
BandwidthIn uint64 `json:"bandwidth_in"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
RequestTimeSum uint64 `json:"request_time_sum"`
RequestTimeCount uint64 `json:"request_time_count"`
Status2xx uint64 `json:"status_2xx"`
Status3xx uint64 `json:"status_3xx"`
Status4xx uint64 `json:"status_4xx"`
Status5xx uint64 `json:"status_5xx"`
}
// WebsiteStatRepo 网站统计数据访问接口
+19 -12
View File
@@ -26,14 +26,21 @@ func (r *websiteStatRepo) Upsert(stats []*biz.WebsiteStat) error {
return batchUpsert(r.db, stats, clause.OnConflict{
Columns: []clause.Column{{Name: "site"}, {Name: "date"}, {Name: "hour"}},
DoUpdates: clause.Assignments(map[string]any{
"pv": gorm.Expr("website_stats.pv + excluded.pv"),
"uv": gorm.Expr("website_stats.uv + excluded.uv"),
"ip": gorm.Expr("website_stats.ip + excluded.ip"),
"bandwidth": gorm.Expr("website_stats.bandwidth + excluded.bandwidth"),
"requests": gorm.Expr("website_stats.requests + excluded.requests"),
"errors": gorm.Expr("website_stats.errors + excluded.errors"),
"spiders": gorm.Expr("website_stats.spiders + excluded.spiders"),
"updated_at": gorm.Expr("excluded.updated_at"),
"pv": gorm.Expr("website_stats.pv + excluded.pv"),
"uv": gorm.Expr("website_stats.uv + excluded.uv"),
"ip": gorm.Expr("website_stats.ip + excluded.ip"),
"bandwidth": gorm.Expr("website_stats.bandwidth + excluded.bandwidth"),
"bandwidth_in": gorm.Expr("website_stats.bandwidth_in + excluded.bandwidth_in"),
"requests": gorm.Expr("website_stats.requests + excluded.requests"),
"errors": gorm.Expr("website_stats.errors + excluded.errors"),
"spiders": gorm.Expr("website_stats.spiders + excluded.spiders"),
"request_time_sum": gorm.Expr("website_stats.request_time_sum + excluded.request_time_sum"),
"request_time_count": gorm.Expr("website_stats.request_time_count + excluded.request_time_count"),
"status2xx": gorm.Expr("website_stats.status2xx + excluded.status2xx"),
"status3xx": gorm.Expr("website_stats.status3xx + excluded.status3xx"),
"status4xx": gorm.Expr("website_stats.status4xx + excluded.status4xx"),
"status5xx": gorm.Expr("website_stats.status5xx + excluded.status5xx"),
"updated_at": gorm.Expr("excluded.updated_at"),
}),
})
}
@@ -41,7 +48,7 @@ func (r *websiteStatRepo) Upsert(stats []*biz.WebsiteStat) error {
func (r *websiteStatRepo) ListByDateRange(start, end string, sites []string) ([]*biz.WebsiteStat, error) {
var items []*biz.WebsiteStat
q := r.db.Model(&biz.WebsiteStat{}).
Select("site, SUM(pv) as pv, SUM(uv) as uv, SUM(ip) as ip, SUM(bandwidth) as bandwidth, SUM(requests) as requests, SUM(errors) as errors, SUM(spiders) as spiders").
Select("site, SUM(pv) as pv, SUM(uv) as uv, SUM(ip) as ip, SUM(bandwidth) as bandwidth, SUM(bandwidth_in) as bandwidth_in, SUM(requests) as requests, SUM(errors) as errors, SUM(spiders) as spiders, SUM(request_time_sum) as request_time_sum, SUM(request_time_count) as request_time_count, SUM(status2xx) as status2xx, SUM(status3xx) as status3xx, SUM(status4xx) as status4xx, SUM(status5xx) as status5xx").
Where("date BETWEEN ? AND ? AND hour = -1", start, end)
if len(sites) > 0 {
q = q.Where("site IN ?", sites)
@@ -53,7 +60,7 @@ func (r *websiteStatRepo) ListByDateRange(start, end string, sites []string) ([]
func (r *websiteStatRepo) DailySeries(start, end string, sites []string) ([]*biz.WebsiteStatSeries, error) {
var series []*biz.WebsiteStatSeries
q := r.db.Model(&biz.WebsiteStat{}).
Select("date as key, COALESCE(SUM(pv), 0) as pv, COALESCE(SUM(uv), 0) as uv, COALESCE(SUM(ip), 0) as ip, COALESCE(SUM(bandwidth), 0) as bandwidth, COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(errors), 0) as errors, COALESCE(SUM(spiders), 0) as spiders").
Select("date as key, COALESCE(SUM(pv), 0) as pv, COALESCE(SUM(uv), 0) as uv, COALESCE(SUM(ip), 0) as ip, COALESCE(SUM(bandwidth), 0) as bandwidth, COALESCE(SUM(bandwidth_in), 0) as bandwidth_in, COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(errors), 0) as errors, COALESCE(SUM(spiders), 0) as spiders, COALESCE(SUM(request_time_sum), 0) as request_time_sum, COALESCE(SUM(request_time_count), 0) as request_time_count, COALESCE(SUM(status2xx), 0) as status2xx, COALESCE(SUM(status3xx), 0) as status3xx, COALESCE(SUM(status4xx), 0) as status4xx, COALESCE(SUM(status5xx), 0) as status5xx").
Where("date BETWEEN ? AND ? AND hour = -1", start, end)
if len(sites) > 0 {
q = q.Where("site IN ?", sites)
@@ -65,7 +72,7 @@ func (r *websiteStatRepo) DailySeries(start, end string, sites []string) ([]*biz
func (r *websiteStatRepo) HourlySeries(date string, sites []string) ([]*biz.WebsiteStatSeries, error) {
var series []*biz.WebsiteStatSeries
q := r.db.Model(&biz.WebsiteStat{}).
Select("CAST(hour AS TEXT) as key, COALESCE(SUM(pv), 0) as pv, COALESCE(SUM(uv), 0) as uv, COALESCE(SUM(ip), 0) as ip, COALESCE(SUM(bandwidth), 0) as bandwidth, COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(errors), 0) as errors, COALESCE(SUM(spiders), 0) as spiders").
Select("CAST(hour AS TEXT) as key, COALESCE(SUM(pv), 0) as pv, COALESCE(SUM(uv), 0) as uv, COALESCE(SUM(ip), 0) as ip, COALESCE(SUM(bandwidth_in), 0) as bandwidth_in, COALESCE(SUM(bandwidth), 0) as bandwidth, COALESCE(SUM(requests), 0) as requests, COALESCE(SUM(errors), 0) as errors, COALESCE(SUM(spiders), 0) as spiders, COALESCE(SUM(request_time_sum), 0) as request_time_sum, COALESCE(SUM(request_time_count), 0) as request_time_count, COALESCE(SUM(status2xx), 0) as status2xx, COALESCE(SUM(status3xx), 0) as status3xx, COALESCE(SUM(status4xx), 0) as status4xx, COALESCE(SUM(status5xx), 0) as status5xx").
Where("date = ? AND hour >= 0", date)
if len(sites) > 0 {
q = q.Where("site IN ?", sites)
@@ -329,7 +336,7 @@ func (r *websiteStatRepo) ListErrors(start, end string, sites []string, status i
func (r *websiteStatRepo) ListSiteStats(start, end string, sites []string) ([]*biz.WebsiteStatSiteItem, error) {
var items []*biz.WebsiteStatSiteItem
q := r.db.Model(&biz.WebsiteStat{}).
Select("site, SUM(pv) as pv, SUM(uv) as uv, SUM(ip) as ip, SUM(bandwidth) as bandwidth, SUM(requests) as requests, SUM(errors) as errors, SUM(spiders) as spiders").
Select("site, SUM(pv) as pv, SUM(uv) as uv, SUM(ip) as ip, SUM(bandwidth) as bandwidth, SUM(bandwidth_in) as bandwidth_in, SUM(requests) as requests, SUM(errors) as errors, SUM(spiders) as spiders, SUM(request_time_sum) as request_time_sum, SUM(request_time_count) as request_time_count, SUM(status2xx) as status2xx, SUM(status3xx) as status3xx, SUM(status4xx) as status4xx, SUM(status5xx) as status5xx").
Where("date BETWEEN ? AND ? AND hour = -1", start, end)
if len(sites) > 0 {
q = q.Where("site IN ?", sites)
+52 -24
View File
@@ -117,17 +117,24 @@ func (r *WebsiteStat) flush() {
// 每日汇总行 (hour = -1)
if siteHasData {
stats = append(stats, &biz.WebsiteStat{
Site: site,
Date: date,
Hour: -1,
PV: snap.PV,
UV: snap.UV,
IP: snap.IP,
Bandwidth: snap.Bandwidth,
Requests: snap.Requests,
Errors: snap.Errors,
Spiders: snap.Spiders,
UpdatedAt: now,
Site: site,
Date: date,
Hour: -1,
PV: snap.PV,
UV: snap.UV,
IP: snap.IP,
Bandwidth: snap.Bandwidth,
BandwidthIn: snap.BandwidthIn,
Requests: snap.Requests,
Errors: snap.Errors,
Spiders: snap.Spiders,
RequestTimeSum: snap.RequestTimeSum,
RequestTimeCount: snap.RequestTimeCount,
Status2xx: snap.Status2xx,
Status3xx: snap.Status3xx,
Status4xx: snap.Status4xx,
Status5xx: snap.Status5xx,
UpdatedAt: now,
})
}
@@ -137,17 +144,24 @@ func (r *WebsiteStat) flush() {
continue
}
stats = append(stats, &biz.WebsiteStat{
Site: site,
Date: date,
Hour: h,
PV: hs.PV,
UV: hs.UV,
IP: hs.IP,
Bandwidth: hs.Bandwidth,
Requests: hs.Requests,
Errors: hs.Errors,
Spiders: hs.Spiders,
UpdatedAt: now,
Site: site,
Date: date,
Hour: h,
PV: hs.PV,
UV: hs.UV,
IP: hs.IP,
Bandwidth: hs.Bandwidth,
BandwidthIn: hs.BandwidthIn,
Requests: hs.Requests,
Errors: hs.Errors,
Spiders: hs.Spiders,
RequestTimeSum: hs.RequestTimeSum,
RequestTimeCount: hs.RequestTimeCount,
Status2xx: hs.Status2xx,
Status3xx: hs.Status3xx,
Status4xx: hs.Status4xx,
Status5xx: hs.Status5xx,
UpdatedAt: now,
})
}
}
@@ -327,9 +341,16 @@ func isZeroSiteSnapshot(s *websitestat.SiteSnapshot) bool {
s.UV == 0 &&
s.IP == 0 &&
s.Bandwidth == 0 &&
s.BandwidthIn == 0 &&
s.Requests == 0 &&
s.Errors == 0 &&
s.Spiders == 0
s.Spiders == 0 &&
s.RequestTimeSum == 0 &&
s.RequestTimeCount == 0 &&
s.Status2xx == 0 &&
s.Status3xx == 0 &&
s.Status4xx == 0 &&
s.Status5xx == 0
}
// isZeroHourSnapshot 判断小时快照是否全为零
@@ -338,7 +359,14 @@ func isZeroHourSnapshot(h *websitestat.HourSnapshot) bool {
h.UV == 0 &&
h.IP == 0 &&
h.Bandwidth == 0 &&
h.BandwidthIn == 0 &&
h.Requests == 0 &&
h.Errors == 0 &&
h.Spiders == 0
h.Spiders == 0 &&
h.RequestTimeSum == 0 &&
h.RequestTimeCount == 0 &&
h.Status2xx == 0 &&
h.Status3xx == 0 &&
h.Status4xx == 0 &&
h.Status5xx == 0
}
+9
View File
@@ -121,4 +121,13 @@ func init() {
return nil
},
})
Migrations = append(Migrations, &gormigrate.Migration{
ID: "20260221-add-perf-traffic-fields",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&biz.WebsiteStat{})
},
Rollback: func(tx *gorm.DB) error {
return nil
},
})
}
+92 -36
View File
@@ -67,14 +67,14 @@ func (s *WebsiteStatService) Overview(w http.ResponseWriter, r *http.Request) {
// 查询当前周期汇总
current, err := s.queryTotals(start, end, sites, today)
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 查询对比周期汇总
previous, err := s.queryTotals(prevStart, prevEnd, sites, today)
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -86,7 +86,7 @@ func (s *WebsiteStatService) Overview(w http.ResponseWriter, r *http.Request) {
series, err = s.queryDailySeries(start, end, sites, today)
}
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -99,14 +99,14 @@ func (s *WebsiteStatService) Overview(w http.ResponseWriter, r *http.Request) {
prevSeries, err = s.queryDailySeries(prevStart, prevEnd, sites, today)
}
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 获取所有网站列表(用于站点选择器)
websites, _, err := s.websiteRepo.List("all", 1, 10000)
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -140,7 +140,7 @@ func (s *WebsiteStatService) SiteStats(w http.ResponseWriter, r *http.Request) {
// DB 查询全部日期范围(含今日)
dbItems, err := s.statRepo.ListSiteStats(start, end, sites)
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
for _, item := range dbItems {
@@ -166,19 +166,33 @@ func (s *WebsiteStatService) SiteStats(w http.ResponseWriter, r *http.Request) {
existing.UV += snap.UV
existing.IP += snap.IP
existing.Bandwidth += snap.Bandwidth
existing.BandwidthIn += snap.BandwidthIn
existing.Requests += snap.Requests
existing.Errors += snap.Errors
existing.Spiders += snap.Spiders
existing.RequestTimeSum += snap.RequestTimeSum
existing.RequestTimeCount += snap.RequestTimeCount
existing.Status2xx += snap.Status2xx
existing.Status3xx += snap.Status3xx
existing.Status4xx += snap.Status4xx
existing.Status5xx += snap.Status5xx
} else {
siteMap[name] = &biz.WebsiteStatSiteItem{
Site: name,
PV: snap.PV,
UV: snap.UV,
IP: snap.IP,
Bandwidth: snap.Bandwidth,
Requests: snap.Requests,
Errors: snap.Errors,
Spiders: snap.Spiders,
Site: name,
PV: snap.PV,
UV: snap.UV,
IP: snap.IP,
Bandwidth: snap.Bandwidth,
BandwidthIn: snap.BandwidthIn,
Requests: snap.Requests,
Errors: snap.Errors,
Spiders: snap.Spiders,
RequestTimeSum: snap.RequestTimeSum,
RequestTimeCount: snap.RequestTimeCount,
Status2xx: snap.Status2xx,
Status3xx: snap.Status3xx,
Status4xx: snap.Status4xx,
Status5xx: snap.Status5xx,
}
}
}
@@ -201,7 +215,7 @@ func (s *WebsiteStatService) SpiderStats(w http.ResponseWriter, r *http.Request)
items, err := s.statRepo.TopSpiders(start, end, sites, 50)
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -228,7 +242,7 @@ func (s *WebsiteStatService) ClientStats(w http.ResponseWriter, r *http.Request)
items, err := s.statRepo.TopClients(start, end, sites, 100)
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -279,7 +293,7 @@ func (s *WebsiteStatService) IPStats(w http.ResponseWriter, r *http.Request) {
items, total, err := s.statRepo.TopIPs(start, end, sites, uint(page), uint(limit))
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -306,7 +320,7 @@ func (s *WebsiteStatService) GeoStats(w http.ResponseWriter, r *http.Request) {
items, err := s.statRepo.TopGeos(start, end, sites, groupBy, country, limit)
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -330,7 +344,7 @@ func (s *WebsiteStatService) URIStats(w http.ResponseWriter, r *http.Request) {
items, total, err := s.statRepo.TopURIs(start, end, sites, uint(page), uint(limit))
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -356,7 +370,7 @@ func (s *WebsiteStatService) ErrorStats(w http.ResponseWriter, r *http.Request)
items, total, err := s.statRepo.ListErrors(start, end, sites, status, uint(page), uint(limit))
if err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -369,7 +383,7 @@ func (s *WebsiteStatService) ErrorStats(w http.ResponseWriter, r *http.Request)
// Clear 清空所有统计数据
func (s *WebsiteStatService) Clear(w http.ResponseWriter, r *http.Request) {
if err := s.statRepo.Clear(); err != nil {
ErrorSystem(w)
Error(w, http.StatusInternalServerError, "%v", err)
return
}
s.aggregator.Reset()
@@ -424,13 +438,20 @@ func (s *WebsiteStatService) UpdateSetting(w http.ResponseWriter, r *http.Reques
}
type statTotals struct {
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
BandwidthIn uint64 `json:"bandwidth_in"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
RequestTimeSum uint64 `json:"request_time_sum"`
RequestTimeCount uint64 `json:"request_time_count"`
Status2xx uint64 `json:"status_2xx"`
Status3xx uint64 `json:"status_3xx"`
Status4xx uint64 `json:"status_4xx"`
Status5xx uint64 `json:"status_5xx"`
}
// parseDateSites 解析公共查询参数 start, end, sites
@@ -469,9 +490,16 @@ func (s *WebsiteStatService) queryTotals(start, end string, sites []string, toda
total.UV += st.UV
total.IP += st.IP
total.Bandwidth += st.Bandwidth
total.BandwidthIn += st.BandwidthIn
total.Requests += st.Requests
total.Errors += st.Errors
total.Spiders += st.Spiders
total.RequestTimeSum += st.RequestTimeSum
total.RequestTimeCount += st.RequestTimeCount
total.Status2xx += st.Status2xx
total.Status3xx += st.Status3xx
total.Status4xx += st.Status4xx
total.Status5xx += st.Status5xx
}
// 叠加今日未刷新的增量
@@ -500,9 +528,16 @@ func (s *WebsiteStatService) mergeLiveTotals(total *statTotals, sites []string)
total.UV += snap.UV
total.IP += snap.IP
total.Bandwidth += snap.Bandwidth
total.BandwidthIn += snap.BandwidthIn
total.Requests += snap.Requests
total.Errors += snap.Errors
total.Spiders += snap.Spiders
total.RequestTimeSum += snap.RequestTimeSum
total.RequestTimeCount += snap.RequestTimeCount
total.Status2xx += snap.Status2xx
total.Status3xx += snap.Status3xx
total.Status4xx += snap.Status4xx
total.Status5xx += snap.Status5xx
}
}
@@ -543,19 +578,33 @@ func (s *WebsiteStatService) queryHourlySeries(date string, sites []string, toda
existing.UV += hs.UV
existing.IP += hs.IP
existing.Bandwidth += hs.Bandwidth
existing.BandwidthIn += hs.BandwidthIn
existing.Requests += hs.Requests
existing.Errors += hs.Errors
existing.Spiders += hs.Spiders
existing.RequestTimeSum += hs.RequestTimeSum
existing.RequestTimeCount += hs.RequestTimeCount
existing.Status2xx += hs.Status2xx
existing.Status3xx += hs.Status3xx
existing.Status4xx += hs.Status4xx
existing.Status5xx += hs.Status5xx
} else {
hourMap[h] = &biz.WebsiteStatSeries{
Key: strconv.Itoa(h),
PV: hs.PV,
UV: hs.UV,
IP: hs.IP,
Bandwidth: hs.Bandwidth,
Requests: hs.Requests,
Errors: hs.Errors,
Spiders: hs.Spiders,
Key: strconv.Itoa(h),
PV: hs.PV,
UV: hs.UV,
IP: hs.IP,
Bandwidth: hs.Bandwidth,
BandwidthIn: hs.BandwidthIn,
Requests: hs.Requests,
Errors: hs.Errors,
Spiders: hs.Spiders,
RequestTimeSum: hs.RequestTimeSum,
RequestTimeCount: hs.RequestTimeCount,
Status2xx: hs.Status2xx,
Status3xx: hs.Status3xx,
Status4xx: hs.Status4xx,
Status5xx: hs.Status5xx,
}
}
}
@@ -613,9 +662,16 @@ func (s *WebsiteStatService) queryDailySeries(start, end string, sites []string,
todayData.UV += snap.UV
todayData.IP += snap.IP
todayData.Bandwidth += snap.Bandwidth
todayData.BandwidthIn += snap.BandwidthIn
todayData.Requests += snap.Requests
todayData.Errors += snap.Errors
todayData.Spiders += snap.Spiders
todayData.RequestTimeSum += snap.RequestTimeSum
todayData.RequestTimeCount += snap.RequestTimeCount
todayData.Status2xx += snap.Status2xx
todayData.Status3xx += snap.Status3xx
todayData.Status4xx += snap.Status4xx
todayData.Status5xx += snap.Status5xx
}
}
+86 -23
View File
@@ -28,19 +28,23 @@ type Aggregator struct {
}
type hourBucket struct {
pv, requests, errors, spiders, bandwidth uint64
ips map[string]struct{}
uvs map[string]struct{}
pv, requests, errors, spiders, bandwidth uint64
bandwidthIn, requestTimeSum, requestTimeCount uint64
status2xx, status3xx, status4xx, status5xx uint64
ips map[string]struct{}
uvs map[string]struct{}
// drain 后记录 set 大小,用于计算增量
lastUVCount, lastIPCount uint64
}
type siteDay struct {
pv, requests, errors, spiders, bandwidth uint64
ips map[string]struct{}
uvs map[string]struct{}
hours [24]*hourBucket
pv, requests, errors, spiders, bandwidth uint64
bandwidthIn, requestTimeSum, requestTimeCount uint64
status2xx, status3xx, status4xx, status5xx uint64
ips map[string]struct{}
uvs map[string]struct{}
hours [24]*hourBucket
// drain 后记录 set 大小,用于计算增量
lastUVCount, lastIPCount uint64
@@ -140,10 +144,37 @@ func (a *Aggregator) Record(entry *LogEntry) {
// 更新每日总计
sd.requests++
sd.bandwidth += entry.Bytes
sd.bandwidthIn += entry.ReqLength
// 更新小时桶
hb.requests++
hb.bandwidth += entry.Bytes
hb.bandwidthIn += entry.ReqLength
// 请求耗时聚合
if entry.RequestTime > 0 {
ms := uint64(entry.RequestTime * 1000)
sd.requestTimeSum += ms
sd.requestTimeCount++
hb.requestTimeSum += ms
hb.requestTimeCount++
}
// 状态码分组
switch {
case entry.Status >= 200 && entry.Status < 300:
sd.status2xx++
hb.status2xx++
case entry.Status >= 300 && entry.Status < 400:
sd.status3xx++
hb.status3xx++
case entry.Status >= 400 && entry.Status < 500:
sd.status4xx++
hb.status4xx++
case entry.Status >= 500 && entry.Status < 600:
sd.status5xx++
hb.status5xx++
}
// UV: IP+UA 去重(每日 + 每小时),超限后不再插入
if len(sd.uvs) < a.UVMaxKeys {
@@ -286,6 +317,13 @@ func (a *Aggregator) DrainSnapshot() (snapshotsByDate map[string]map[string]*Sit
sd.errors = saturatingSub(sd.errors, snap.Errors)
sd.spiders = saturatingSub(sd.spiders, snap.Spiders)
sd.bandwidth = saturatingSub(sd.bandwidth, snap.Bandwidth)
sd.bandwidthIn = saturatingSub(sd.bandwidthIn, snap.BandwidthIn)
sd.requestTimeSum = saturatingSub(sd.requestTimeSum, snap.RequestTimeSum)
sd.requestTimeCount = saturatingSub(sd.requestTimeCount, snap.RequestTimeCount)
sd.status2xx = saturatingSub(sd.status2xx, snap.Status2xx)
sd.status3xx = saturatingSub(sd.status3xx, snap.Status3xx)
sd.status4xx = saturatingSub(sd.status4xx, snap.Status4xx)
sd.status5xx = saturatingSub(sd.status5xx, snap.Status5xx)
sd.lastUVCount = cappedAdd(sd.lastUVCount, snap.UV, uint64(len(sd.uvs)))
sd.lastIPCount = cappedAdd(sd.lastIPCount, snap.IP, uint64(len(sd.ips)))
@@ -302,6 +340,13 @@ func (a *Aggregator) DrainSnapshot() (snapshotsByDate map[string]map[string]*Sit
hb.errors = saturatingSub(hb.errors, hs.Errors)
hb.spiders = saturatingSub(hb.spiders, hs.Spiders)
hb.bandwidth = saturatingSub(hb.bandwidth, hs.Bandwidth)
hb.bandwidthIn = saturatingSub(hb.bandwidthIn, hs.BandwidthIn)
hb.requestTimeSum = saturatingSub(hb.requestTimeSum, hs.RequestTimeSum)
hb.requestTimeCount = saturatingSub(hb.requestTimeCount, hs.RequestTimeCount)
hb.status2xx = saturatingSub(hb.status2xx, hs.Status2xx)
hb.status3xx = saturatingSub(hb.status3xx, hs.Status3xx)
hb.status4xx = saturatingSub(hb.status4xx, hs.Status4xx)
hb.status5xx = saturatingSub(hb.status5xx, hs.Status5xx)
hb.lastUVCount = cappedAdd(hb.lastUVCount, hs.UV, uint64(len(hb.uvs)))
hb.lastIPCount = cappedAdd(hb.lastIPCount, hs.IP, uint64(len(hb.ips)))
}
@@ -523,26 +568,40 @@ func snapshotHour(hb *hourBucket) *HourSnapshot {
return nil
}
return &HourSnapshot{
PV: hb.pv,
UV: uint64(len(hb.uvs)) - hb.lastUVCount,
IP: uint64(len(hb.ips)) - hb.lastIPCount,
Bandwidth: hb.bandwidth,
Requests: hb.requests,
Errors: hb.errors,
Spiders: hb.spiders,
PV: hb.pv,
UV: uint64(len(hb.uvs)) - hb.lastUVCount,
IP: uint64(len(hb.ips)) - hb.lastIPCount,
Bandwidth: hb.bandwidth,
BandwidthIn: hb.bandwidthIn,
Requests: hb.requests,
Errors: hb.errors,
Spiders: hb.spiders,
RequestTimeSum: hb.requestTimeSum,
RequestTimeCount: hb.requestTimeCount,
Status2xx: hb.status2xx,
Status3xx: hb.status3xx,
Status4xx: hb.status4xx,
Status5xx: hb.status5xx,
}
}
// snapshotSite 从 siteDay 生成增量快照
func snapshotSite(sd *siteDay) *SiteSnapshot {
snap := &SiteSnapshot{
PV: sd.pv,
UV: uint64(len(sd.uvs)) - sd.lastUVCount,
IP: uint64(len(sd.ips)) - sd.lastIPCount,
Bandwidth: sd.bandwidth,
Requests: sd.requests,
Errors: sd.errors,
Spiders: sd.spiders,
PV: sd.pv,
UV: uint64(len(sd.uvs)) - sd.lastUVCount,
IP: uint64(len(sd.ips)) - sd.lastIPCount,
Bandwidth: sd.bandwidth,
BandwidthIn: sd.bandwidthIn,
Requests: sd.requests,
Errors: sd.errors,
Spiders: sd.spiders,
RequestTimeSum: sd.requestTimeSum,
RequestTimeCount: sd.requestTimeCount,
Status2xx: sd.status2xx,
Status3xx: sd.status3xx,
Status4xx: sd.status4xx,
Status5xx: sd.status5xx,
}
for h := range 24 {
snap.Hours[h] = snapshotHour(sd.hours[h])
@@ -552,14 +611,18 @@ func snapshotSite(sd *siteDay) *SiteSnapshot {
// hasPending 判断站点是否仍有未落库增量
func hasPending(sd *siteDay) bool {
if sd.pv > 0 || sd.requests > 0 || sd.errors > 0 || sd.spiders > 0 || sd.bandwidth > 0 {
if sd.pv > 0 || sd.requests > 0 || sd.errors > 0 || sd.spiders > 0 || sd.bandwidth > 0 ||
sd.bandwidthIn > 0 || sd.requestTimeSum > 0 || sd.requestTimeCount > 0 ||
sd.status2xx > 0 || sd.status3xx > 0 || sd.status4xx > 0 || sd.status5xx > 0 {
return true
}
for _, hb := range sd.hours {
if hb == nil {
continue
}
if hb.pv > 0 || hb.requests > 0 || hb.errors > 0 || hb.spiders > 0 || hb.bandwidth > 0 {
if hb.pv > 0 || hb.requests > 0 || hb.errors > 0 || hb.spiders > 0 || hb.bandwidth > 0 ||
hb.bandwidthIn > 0 || hb.requestTimeSum > 0 || hb.requestTimeCount > 0 ||
hb.status2xx > 0 || hb.status3xx > 0 || hb.status4xx > 0 || hb.status5xx > 0 {
return true
}
}
+18
View File
@@ -81,6 +81,7 @@ func ParseLogEntry(tag string, data []byte) (*LogEntry, error) {
Method: getString(v, "method"),
ContentType: getString(v, "content_type"),
ReqLength: uint64(getInt(v, "req_length")),
RequestTime: getFloat(v, "rt"),
}
// 仅 4xx/5xx 时才提取 body
@@ -112,3 +113,20 @@ func getInt(v *fastjson.Value, key string) int64 {
return 0
}
}
// getFloat 从 JSON value 中提取浮点数字段
func getFloat(v *fastjson.Value, key string) float64 {
val := v.Get(key)
if val == nil {
return 0
}
switch val.Type() {
case fastjson.TypeNumber:
return val.GetFloat64()
case fastjson.TypeString:
f, _ := strconv.ParseFloat(string(val.GetStringBytes()), 64)
return f
default:
return 0
}
}
+40 -25
View File
@@ -2,39 +2,54 @@ package websitestat
// LogEntry 解析后的访问日志条目
type LogEntry struct {
Site string // 来自 syslog tag
URI string // 请求 URI
Status int // HTTP 状态码
Bytes uint64 // 响应体大小
UA string // User-Agent
IP string // 客户端 IP
Method string // 请求方法
Body string // 请求体(仅 400-599 状态码时保留)
ContentType string // 响应 Content-TypePV 判定用)
ReqLength uint64 // 请求大小(入站流量)
Site string // 来自 syslog tag
URI string // 请求 URI
Status int // HTTP 状态码
Bytes uint64 // 响应体大小
UA string // User-Agent
IP string // 客户端 IP
Method string // 请求方法
Body string // 请求体(仅 400-599 状态码时保留)
ContentType string // 响应 Content-TypePV 判定用)
ReqLength uint64 // 请求大小(入站流量)
RequestTime float64 // 请求耗时(秒)
}
// HourSnapshot 小时粒度快照
type HourSnapshot struct {
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
BandwidthIn uint64 `json:"bandwidth_in"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
RequestTimeSum uint64 `json:"request_time_sum"`
RequestTimeCount uint64 `json:"request_time_count"`
Status2xx uint64 `json:"status_2xx"`
Status3xx uint64 `json:"status_3xx"`
Status4xx uint64 `json:"status_4xx"`
Status5xx uint64 `json:"status_5xx"`
}
// SiteSnapshot 站点快照(用于 DB flush
type SiteSnapshot struct {
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
Hours [24]*HourSnapshot `json:"-"`
PV uint64 `json:"pv"`
UV uint64 `json:"uv"`
IP uint64 `json:"ip"`
Bandwidth uint64 `json:"bandwidth"`
BandwidthIn uint64 `json:"bandwidth_in"`
Requests uint64 `json:"requests"`
Errors uint64 `json:"errors"`
Spiders uint64 `json:"spiders"`
RequestTimeSum uint64 `json:"request_time_sum"`
RequestTimeCount uint64 `json:"request_time_count"`
Status2xx uint64 `json:"status_2xx"`
Status3xx uint64 `json:"status_3xx"`
Status4xx uint64 `json:"status_4xx"`
Status5xx uint64 `json:"status_5xx"`
Hours [24]*HourSnapshot `json:"-"`
}
// RealtimeStats 实时统计
+215 -4
View File
@@ -26,6 +26,13 @@ interface StatTotals {
requests: number
errors: number
spiders: number
bandwidth_in: number
request_time_sum: number
request_time_count: number
status_2xx: number
status_3xx: number
status_4xx: number
status_5xx: number
}
interface SeriesPoint {
@@ -37,6 +44,13 @@ interface SeriesPoint {
requests: number
errors: number
spiders: number
bandwidth_in: number
request_time_sum: number
request_time_count: number
status_2xx: number
status_3xx: number
status_4xx: number
status_5xx: number
}
interface OverviewData {
@@ -54,7 +68,14 @@ const emptyTotals: StatTotals = {
bandwidth: 0,
requests: 0,
errors: 0,
spiders: 0
spiders: 0,
bandwidth_in: 0,
request_time_sum: 0,
request_time_count: 0,
status_2xx: 0,
status_3xx: 0,
status_4xx: 0,
status_5xx: 0
}
const overview = ref<OverviewData>({
@@ -239,20 +260,192 @@ const previousLabel = computed(() => {
if (preset === 'yesterday') return $gettext('Day Before Yesterday')
return $gettext('Previous Period')
})
// ============ 性能/负载图表 ============
const perfChartOption = computed<EChartsOption>(() => {
const series = overview.value.series || []
const xData = series.map((s) => formatXLabel(s.key))
const secondsPerSlot = isSingleDay.value ? 3600 : 86400
const qpsData = series.map((s) => {
if (!s.requests) return 0
return Number((s.requests / secondsPerSlot).toFixed(2))
})
const avgRtData = series.map((s) => {
if (!s.request_time_count) return 0
return Number((s.request_time_sum / s.request_time_count).toFixed(1))
})
// 计算最新非零值用于 legend
const lastQps = qpsData.findLast((v) => v > 0) ?? 0
const lastRt = avgRtData.findLast((v) => v > 0) ?? 0
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
if (!Array.isArray(params) || params.length === 0) return ''
let html = `<div style="font-size:12px"><div style="margin-bottom:4px;font-weight:bold">${params[0].name}</div>`
for (const p of params) {
const unit = p.seriesIndex === 0 ? '' : 'ms'
html += `<div>${p.marker} ${p.seriesName}: ${p.value}${unit}</div>`
}
html += '</div>'
return html
}
},
legend: {
top: 0,
right: 0,
formatter: (name: string) => {
if (name === 'QPS') return `QPS: ${lastQps}`
return `${$gettext('Avg Response Time')}: ${lastRt}ms`
}
},
grid: { left: 50, right: 50, top: 40, bottom: 30 },
xAxis: {
type: 'category',
boundaryGap: false,
data: xData,
axisLabel: {
interval: isSingleDay.value
? (_: number, value: string) => {
const hour = parseInt(value.split(':')[0] || '0', 10)
return hour % 3 === 0
}
: 'auto'
}
},
yAxis: [
{
type: 'value',
name: 'QPS',
splitLine: { lineStyle: { type: 'dashed' } }
},
{
type: 'value',
name: 'ms',
splitLine: { show: false }
}
],
series: [
{
name: 'QPS',
type: 'line',
smooth: true,
symbol: 'none',
areaStyle: { opacity: 0.15 },
lineStyle: { width: 2 },
itemStyle: { color: '#18a058' },
data: qpsData
},
{
name: $gettext('Avg Response Time'),
type: 'line',
smooth: true,
symbol: 'none',
yAxisIndex: 1,
areaStyle: { opacity: 0.1 },
lineStyle: { width: 2 },
itemStyle: { color: '#2080f0' },
data: avgRtData
}
]
}
})
// ============ 流量图表 ============
const trafficChartOption = computed<EChartsOption>(() => {
const series = overview.value.series || []
const xData = series.map((s) => formatXLabel(s.key))
const outData = series.map((s) => s.bandwidth || 0)
const inData = series.map((s) => s.bandwidth_in || 0)
// 汇总值用于 legend
const totalOut = outData.reduce((a, b) => a + b, 0)
const totalIn = inData.reduce((a, b) => a + b, 0)
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
if (!Array.isArray(params) || params.length === 0) return ''
let html = `<div style="font-size:12px"><div style="margin-bottom:4px;font-weight:bold">${params[0].name}</div>`
for (const p of params) {
html += `<div>${p.marker} ${p.seriesName}: ${formatBytes(p.value)}</div>`
}
html += '</div>'
return html
}
},
legend: {
top: 0,
right: 0,
formatter: (name: string) => {
if (name === $gettext('Outbound')) return `${$gettext('Outbound')}: ${formatBytes(totalOut)}`
return `${$gettext('Inbound')}: ${formatBytes(totalIn)}`
}
},
grid: { left: 60, right: 20, top: 40, bottom: 30 },
xAxis: {
type: 'category',
boundaryGap: false,
data: xData,
axisLabel: {
interval: isSingleDay.value
? (_: number, value: string) => {
const hour = parseInt(value.split(':')[0] || '0', 10)
return hour % 3 === 0
}
: 'auto'
}
},
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => formatBytes(v) },
splitLine: { lineStyle: { type: 'dashed' } }
},
series: [
{
name: $gettext('Outbound'),
type: 'line',
smooth: true,
symbol: 'none',
areaStyle: { opacity: 0.15 },
lineStyle: { width: 2 },
itemStyle: { color: '#18a058' },
data: outData
},
{
name: $gettext('Inbound'),
type: 'line',
smooth: true,
symbol: 'none',
areaStyle: { opacity: 0.1 },
lineStyle: { width: 2 },
itemStyle: { color: '#2080f0' },
data: inData
}
]
}
})
</script>
<template>
<n-flex vertical :size="20">
<!-- 统计卡片 -->
<n-spin :show="loading">
<div class="grid grid-cols-3 gap-12 sm:grid-cols-5 lg:grid-cols-9">
<div class="gap-12 grid grid-cols-3 lg:grid-cols-9 sm:grid-cols-5">
<n-card v-for="m in metrics" :key="m.key" :bordered="false" size="small">
<div class="flex flex-col gap-4">
<span class="text-12px text-[var(--text-color-3)]">{{ m.label }}</span>
<span class="text-20px font-bold">{{
formatValue(overview.current[m.key] || 0, m.isBytes)
}}</span>
<div class="text-12px flex items-center gap-4">
<div class="text-12px flex gap-4 items-center">
<span
:class="{
'text-[var(--success-color)]':
@@ -294,7 +487,7 @@ const previousLabel = computed(() => {
<!-- 趋势图 -->
<n-card :bordered="false">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-8">
<div class="flex flex-wrap gap-8 items-center justify-between">
<n-radio-group v-model:value="activeMetric" size="small">
<n-radio-button v-for="m in metrics" :key="m.key" :value="m.key">
{{ m.label }}
@@ -309,5 +502,23 @@ const previousLabel = computed(() => {
<v-chart class="h-350px" :option="chartOption" autoresize />
</n-spin>
</n-card>
<!-- 性能/负载 + 流量 -->
<n-grid :cols="2" :x-gap="20">
<n-gi>
<n-card :bordered="false" :title="$gettext('Performance / Load')">
<n-spin :show="loading">
<v-chart class="h-280px" :option="perfChartOption" autoresize />
</n-spin>
</n-card>
</n-gi>
<n-gi>
<n-card :bordered="false" :title="$gettext('Traffic')">
<n-spin :show="loading">
<v-chart class="h-280px" :option="trafficChartOption" autoresize />
</n-spin>
</n-card>
</n-gi>
</n-grid>
</n-flex>
</template>