feat: 网站统计

This commit is contained in:
耗子
2026-02-18 18:17:16 +08:00
parent aa2e54f3f1
commit fa30728753
29 changed files with 1854 additions and 18 deletions
+6 -2
View File
@@ -35,6 +35,7 @@ import (
"github.com/acepanel/panel/internal/job"
"github.com/acepanel/panel/internal/route"
"github.com/acepanel/panel/internal/service"
"github.com/acepanel/panel/pkg/websitestat"
)
import (
@@ -143,6 +144,9 @@ func initAce() (*app.Ace, error) {
webHookService := service.NewWebHookService(webHookRepo)
templateRepo := data.NewTemplateRepo(locale, cacheRepo)
templateService := service.NewTemplateService(locale, templateRepo, settingRepo)
websiteStatRepo := data.NewWebsiteStatRepo(db)
aggregator := websitestat.NewAggregator()
websiteStatService := service.NewWebsiteStatService(settingRepo, websiteStatRepo, websiteRepo, aggregator)
apacheApp := apache.NewApp(locale)
codeserverApp := codeserver.NewApp()
dockerApp := docker.NewApp()
@@ -165,7 +169,7 @@ func initAce() (*app.Ace, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(apacheApp, codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseRedisService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, environmentDotnetService, cronService, processService, safeService, firewallService, firewallScanService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxNetworkService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, toolboxMigrationService, webHookService, templateService, loader)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseRedisService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, environmentDotnetService, cronService, processService, safeService, firewallService, firewallScanService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxNetworkService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, toolboxMigrationService, webHookService, templateService, websiteStatService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo, settingRepo)
ws := route.NewWs(wsService, toolboxMigrationService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
@@ -181,7 +185,7 @@ func initAce() (*app.Ace, error) {
return nil, err
}
gormigrate := bootstrap.NewMigrate(db)
jobs := job.NewJobs(config, db, logger, settingRepo, certRepo, certAccountRepo, backupRepo, cacheRepo, taskRepo, scanEventRepo)
jobs := job.NewJobs(config, db, logger, settingRepo, certRepo, certAccountRepo, backupRepo, cacheRepo, taskRepo, scanEventRepo, websiteStatRepo, aggregator)
cron, err := bootstrap.NewCron(config, logger, jobs)
if err != nil {
return nil, err
+3
View File
@@ -136,6 +136,8 @@ github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OI
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -303,6 +305,7 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+1
View File
@@ -33,6 +33,7 @@ const (
SettingKeyScanAware SettingKey = "scan_aware"
SettingKeyScanAwareDays SettingKey = "scan_aware_days"
SettingKeyScanAwareInterfaces SettingKey = "scan_aware_interfaces"
SettingKeyWebsiteStatDays SettingKey = "website_stat_days"
)
type Setting struct {
+65
View File
@@ -0,0 +1,65 @@
package biz
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"`
}
// WebsiteErrorLog 网站错误日志(400-599 状态码详情)
type WebsiteErrorLog struct {
ID uint `gorm:"primaryKey" json:"id"`
Site string `gorm:"not null;index:idx_werr_site_time" json:"site"`
URI string `gorm:"not null" json:"uri"`
Method string `gorm:"not null" json:"method"`
Status int `gorm:"not null;index" json:"status"`
IP string `gorm:"not null" json:"ip"`
UA string `gorm:"not null" json:"ua"`
Body string `gorm:"type:text" json:"body"`
CreatedAt time.Time `gorm:"index:idx_werr_site_time" json:"created_at"`
}
// 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"`
}
// WebsiteStatRepo 网站统计数据访问接口
type WebsiteStatRepo interface {
// Upsert 批量写入/更新统计数据(含每日汇总和小时数据)
Upsert(stats []*WebsiteStat) error
// ListByDateRange 按日期范围查询每站汇总(仅 hour=-1 的每日行)
ListByDateRange(start, end string, sites []string) ([]*WebsiteStat, error)
// DailySeries 按日分组的时间序列
DailySeries(start, end string, sites []string) ([]*WebsiteStatSeries, error)
// HourlySeries 按小时的时间序列(单天查询)
HourlySeries(date string, sites []string) ([]*WebsiteStatSeries, error)
// ClearBefore 清理过期数据
ClearBefore(date string) error
// InsertErrors 批量写入错误日志
InsertErrors(errors []*WebsiteErrorLog) error
// ClearErrorsBefore 清理过期错误日志
ClearErrorsBefore(t time.Time) error
// Clear 清空所有统计数据
Clear() error
}
+6 -2
View File
@@ -1,6 +1,10 @@
package bootstrap
import "github.com/google/wire"
import (
"github.com/google/wire"
"github.com/acepanel/panel/pkg/websitestat"
)
// ProviderSet is bootstrap providers.
var ProviderSet = wire.NewSet(NewConf, NewT, NewLog, NewCli, NewValidator, NewRouter, NewTLSReloader, NewHttp, NewDB, NewMigrate, NewLoader, NewSession, NewCron, NewRunner)
var ProviderSet = wire.NewSet(NewConf, NewT, NewLog, NewCli, NewValidator, NewRouter, NewTLSReloader, NewHttp, NewDB, NewMigrate, NewLoader, NewSession, NewCron, NewRunner, websitestat.NewAggregator)
+1
View File
@@ -35,4 +35,5 @@ var ProviderSet = wire.NewSet(
NewUserTokenRepo,
NewWebHookRepo,
NewWebsiteRepo,
NewWebsiteStatRepo,
)
+39
View File
@@ -229,6 +229,9 @@ func (r *websiteRepo) Get(id uint) (*types.WebsiteSetting, error) {
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
setting.CustomConfigs = r.getCustomConfigs(configDir)
// 访问统计
setting.StatEnabled = vhost.Config("021-stats-log.conf", "site") != ""
return setting, err
}
@@ -752,6 +755,42 @@ func (r *websiteRepo) Update(ctx context.Context, req *request.WebsiteUpdate) er
}
}
// 访问统计
webServer, _ := r.setting.Get(biz.SettingKeyWebserver)
if webServer == "nginx" {
if req.StatEnabled {
formatConf := fmt.Sprintf(`log_format ace_stat_%s escape=json
'{"uri":"$request_uri",'
'"status":$status,'
'"bytes":$body_bytes_sent,'
'"ua":"$http_user_agent",'
'"ip":"$remote_addr",'
'"host":"$host",'
'"method":"$request_method",'
'"referer":"$http_referer",'
'"xff":"$http_x_forwarded_for",'
'"rt":$request_time,'
'"proto":"$server_protocol",'
'"port":"$remote_port",'
'"body":"$request_body",'
'"content_type":"$sent_http_content_type",'
'"req_length":$request_length,'
'"https":"$https",'
'"upstream_time":"$upstream_response_time",'
'"upstream_status":"$upstream_status"}';`, website.Name)
if err = vhost.SetConfig("010-stat-format.conf", "shared", formatConf); err != nil {
return err
}
logConf := fmt.Sprintf("client_body_in_single_buffer on;\naccess_log syslog:server=unix:/tmp/ace_stats.sock,nohostname,tag=%s ace_stat_%s;", website.Name, website.Name)
if err = vhost.SetConfig("021-stats-log.conf", "site", logConf); err != nil {
return err
}
} else {
_ = vhost.RemoveConfig("010-stat-format.conf", "shared")
_ = vhost.RemoveConfig("021-stats-log.conf", "site")
}
}
// 自定义配置
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
if err = r.saveCustomConfigs(configDir, req.CustomConfigs); err != nil {
+109
View File
@@ -0,0 +1,109 @@
package data
import (
"fmt"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/acepanel/panel/internal/biz"
)
type websiteStatRepo struct {
db *gorm.DB
}
// NewWebsiteStatRepo 创建网站统计数据访问实例
func NewWebsiteStatRepo(db *gorm.DB) biz.WebsiteStatRepo {
return &websiteStatRepo{db: db}
}
func (r *websiteStatRepo) Upsert(stats []*biz.WebsiteStat) error {
if len(stats) == 0 {
return nil
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "site"}, {Name: "date"}, {Name: "hour"}},
DoUpdates: clause.Assignments(map[string]any{
"pv": gorm.Expr("excluded.pv"),
"uv": gorm.Expr("excluded.uv"),
"ip": gorm.Expr("excluded.ip"),
"bandwidth": gorm.Expr("excluded.bandwidth"),
"requests": gorm.Expr("excluded.requests"),
"errors": gorm.Expr("excluded.errors"),
"spiders": gorm.Expr("excluded.spiders"),
"updated_at": gorm.Expr("excluded.updated_at"),
}),
}).Create(stats).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").
Where("date BETWEEN ? AND ? AND hour = -1", start, end)
if len(sites) > 0 {
q = q.Where("site IN ?", sites)
}
err := q.Group("site").Scan(&items).Error
return items, err
}
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").
Where("date BETWEEN ? AND ? AND hour = -1", start, end)
if len(sites) > 0 {
q = q.Where("site IN ?", sites)
}
err := q.Group("date").Order("date ASC").Scan(&series).Error
return series, err
}
func (r *websiteStatRepo) HourlySeries(date string, sites []string) ([]*biz.WebsiteStatSeries, error) {
var series []*biz.WebsiteStatSeries
q := r.db.Model(&biz.WebsiteStat{}).
Select(fmt.Sprintf("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")).
Where("date = ? AND hour >= 0", date)
if len(sites) > 0 {
q = q.Where("site IN ?", sites)
}
err := q.Group("hour").Order("hour ASC").Scan(&series).Error
return series, err
}
func (r *websiteStatRepo) ClearBefore(date string) error {
return r.db.Where("date < ?", date).Delete(&biz.WebsiteStat{}).Error
}
func (r *websiteStatRepo) InsertErrors(errors []*biz.WebsiteErrorLog) error {
if len(errors) == 0 {
return nil
}
const batchSize = 100
for i := 0; i < len(errors); i += batchSize {
end := i + batchSize
if end > len(errors) {
end = len(errors)
}
if err := r.db.Create(errors[i:end]).Error; err != nil {
return err
}
}
return nil
}
func (r *websiteStatRepo) ClearErrorsBefore(t time.Time) error {
return r.db.Where("created_at < ?", t).Delete(&biz.WebsiteErrorLog{}).Error
}
func (r *websiteStatRepo) Clear() error {
if err := r.db.Where("1 = 1").Delete(&biz.WebsiteStat{}).Error; err != nil {
return err
}
return r.db.Where("1 = 1").Delete(&biz.WebsiteErrorLog{}).Error
}
+6 -5
View File
@@ -86,11 +86,12 @@ type WebsiteUpdate struct {
Redirects []types.Redirect `json:"redirects"`
// 高级设置
AccessLog string `json:"access_log"` // 访问日志路径
ErrorLog string `json:"error_log"` // 错误日志路径
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
BasicAuth map[string]string `json:"basic_auth"` // 基本认证配置
StatEnabled bool `json:"stat_enabled"` // 是否启用访问统计
AccessLog string `json:"access_log"` // 访问日志路径
ErrorLog string `json:"error_log"` // 错误日志路径
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
BasicAuth map[string]string `json:"basic_auth"` // 基本认证配置
// 自定义配置
CustomConfigs []WebsiteCustomConfig `json:"custom_configs"`
+6
View File
@@ -0,0 +1,6 @@
package request
// WebsiteStatSetting 网站统计设置
type WebsiteStatSetting struct {
Days uint `json:"days" validate:"required|min:1|max:365"`
}
+9 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/pkg/config"
"github.com/acepanel/panel/pkg/websitestat"
)
var ProviderSet = wire.NewSet(NewJobs)
@@ -24,9 +25,11 @@ type Jobs struct {
cache biz.CacheRepo
task biz.TaskRepo
scanRepo biz.ScanEventRepo
statRepo biz.WebsiteStatRepo
aggregator *websitestat.Aggregator
}
func NewJobs(conf *config.Config, db *gorm.DB, log *slog.Logger, setting biz.SettingRepo, cert biz.CertRepo, certAccount biz.CertAccountRepo, backup biz.BackupRepo, cache biz.CacheRepo, task biz.TaskRepo, scanRepo biz.ScanEventRepo) *Jobs {
func NewJobs(conf *config.Config, db *gorm.DB, log *slog.Logger, setting biz.SettingRepo, cert biz.CertRepo, certAccount biz.CertAccountRepo, backup biz.BackupRepo, cache biz.CacheRepo, task biz.TaskRepo, scanRepo biz.ScanEventRepo, statRepo biz.WebsiteStatRepo, aggregator *websitestat.Aggregator) *Jobs {
return &Jobs{
conf: conf,
db: db,
@@ -38,6 +41,8 @@ func NewJobs(conf *config.Config, db *gorm.DB, log *slog.Logger, setting biz.Set
cache: cache,
task: task,
scanRepo: scanRepo,
statRepo: statRepo,
aggregator: aggregator,
}
}
@@ -54,6 +59,9 @@ func (r *Jobs) Register(c *cron.Cron) error {
if _, err := c.AddJob("0 2 * * *", NewPanelTask(r.conf, r.db, r.log, r.backup, r.cache, r.task, r.setting)); err != nil {
return err
}
if _, err := c.AddJob("* * * * *", NewWebsiteStat(r.log, r.setting, r.statRepo, r.aggregator)); err != nil {
return err
}
return nil
}
+175
View File
@@ -0,0 +1,175 @@
package job
import (
"log/slog"
"time"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/pkg/websitestat"
)
// WebsiteStat 网站统计后台任务
type WebsiteStat struct {
log *slog.Logger
setting biz.SettingRepo
statRepo biz.WebsiteStatRepo
aggregator *websitestat.Aggregator
listener *websitestat.Listener
started bool
}
// NewWebsiteStat 创建网站统计任务
func NewWebsiteStat(log *slog.Logger, setting biz.SettingRepo, statRepo biz.WebsiteStatRepo, aggregator *websitestat.Aggregator) *WebsiteStat {
return &WebsiteStat{
log: log,
setting: setting,
statRepo: statRepo,
aggregator: aggregator,
}
}
func (r *WebsiteStat) Run() {
if app.Status != app.StatusNormal {
return
}
r.ensureListener()
r.flush()
r.flushErrors()
r.cleanup()
}
// ensureListener 确保 listener goroutine 已启动
func (r *WebsiteStat) ensureListener() {
if r.started {
return
}
listener, err := websitestat.NewListener("/tmp/ace_stats.sock", r.log)
if err != nil {
r.log.Warn("fail to start website stat listener", slog.Any("err", err))
return
}
r.listener = listener
r.started = true
go r.readLoop()
}
// readLoop 持续读取 syslog 消息并记录到聚合器
func (r *WebsiteStat) readLoop() {
for {
tag, data, err := r.listener.Read()
if err != nil {
r.log.Warn("failed to read from website stat listener", slog.Any("err", err))
r.started = false
return
}
entry, err := websitestat.ParseLogEntry(tag, data)
if err != nil {
continue
}
r.aggregator.Record(entry)
}
}
// flush 将内存快照写入数据库(含每日汇总和小时数据)
func (r *WebsiteStat) flush() {
date, snapshots := r.aggregator.Snapshot()
if len(snapshots) == 0 {
return
}
now := time.Now()
var stats []*biz.WebsiteStat
for site, snap := range snapshots {
// 每日汇总行 (hour = -1)
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,
})
// 每小时行 (hour = 0-23)
for h, hs := range snap.Hours {
if hs == nil {
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,
})
}
}
if err := r.statRepo.Upsert(stats); err != nil {
r.log.Warn("failed to upsert website stats", slog.Any("err", err))
}
}
// flushErrors 将错误日志缓冲写入数据库
func (r *WebsiteStat) flushErrors() {
entries := r.aggregator.DrainErrors()
if len(entries) == 0 {
return
}
now := time.Now()
errors := make([]*biz.WebsiteErrorLog, 0, len(entries))
for _, e := range entries {
errors = append(errors, &biz.WebsiteErrorLog{
Site: e.Site,
URI: e.URI,
Method: e.Method,
Status: e.Status,
IP: e.IP,
UA: e.UA,
Body: e.Body,
CreatedAt: now,
})
}
if err := r.statRepo.InsertErrors(errors); err != nil {
r.log.Warn("failed to insert website error logs", slog.Any("err", err))
}
}
// cleanup 清理过期数据
func (r *WebsiteStat) cleanup() {
days, err := r.setting.GetInt(biz.SettingKeyWebsiteStatDays, 30)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -days).Format(time.DateOnly)
if err = r.statRepo.ClearBefore(cutoff); err != nil {
r.log.Warn("failed to clear expired website stats", slog.Any("err", err))
}
errCutoff := time.Now().AddDate(0, 0, -days)
if err = r.statRepo.ClearErrorsBefore(errCutoff); err != nil {
r.log.Warn("failed to clear expired website error logs", slog.Any("err", err))
}
}
+15
View File
@@ -86,4 +86,19 @@ func init() {
return tx.Migrator().DropTable(&biz.ScanEvent{})
},
})
Migrations = append(Migrations, &gormigrate.Migration{
ID: "20260218-website-stats",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&biz.WebsiteStat{}); err != nil {
return err
}
return tx.AutoMigrate(&biz.WebsiteErrorLog{})
},
Rollback: func(tx *gorm.DB) error {
if err := tx.Migrator().DropTable(&biz.WebsiteErrorLog{}); err != nil {
return err
}
return tx.Migrator().DropTable(&biz.WebsiteStat{})
},
})
}
+10
View File
@@ -70,6 +70,7 @@ type Http struct {
toolboxMigration *service.ToolboxMigrationService
webhook *service.WebHookService
template *service.TemplateService
websiteStat *service.WebsiteStatService
apps *apploader.Loader
}
@@ -123,6 +124,7 @@ func NewHttp(
toolboxMigration *service.ToolboxMigrationService,
webhook *service.WebHookService,
template *service.TemplateService,
websiteStat *service.WebsiteStatService,
apps *apploader.Loader,
) *Http {
return &Http{
@@ -175,6 +177,7 @@ func NewHttp(
toolboxMigration: toolboxMigration,
webhook: webhook,
template: template,
websiteStat: websiteStat,
apps: apps,
}
}
@@ -246,6 +249,13 @@ func (route *Http) Register(r *chi.Mux) {
r.Post("/{id}/reset_config", route.website.ResetConfig)
r.Post("/{id}/status", route.website.UpdateStatus)
r.Post("/{id}/obtain_cert", route.website.ObtainCert)
// 网站统计
r.Get("/stat/overview", route.websiteStat.Overview)
r.Get("/stat/realtime", route.websiteStat.Realtime)
r.Get("/stat/setting", route.websiteStat.GetSetting)
r.Post("/stat/setting", route.websiteStat.UpdateSetting)
r.Post("/stat/clear", route.websiteStat.Clear)
})
r.Route("/project", func(r chi.Router) {
+1
View File
@@ -46,6 +46,7 @@ var ProviderSet = wire.NewSet(
NewUserTokenService,
NewWebHookService,
NewWebsiteService,
NewWebsiteStatService,
NewToolboxNetworkService,
NewToolboxSystemService,
NewToolboxBenchmarkService,
+367
View File
@@ -0,0 +1,367 @@
package service
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/libtnb/chix"
"github.com/samber/lo"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/websitestat"
)
type WebsiteStatService struct {
setting biz.SettingRepo
statRepo biz.WebsiteStatRepo
websiteRepo biz.WebsiteRepo
aggregator *websitestat.Aggregator
}
func NewWebsiteStatService(setting biz.SettingRepo, statRepo biz.WebsiteStatRepo, websiteRepo biz.WebsiteRepo, aggregator *websitestat.Aggregator) *WebsiteStatService {
return &WebsiteStatService{
setting: setting,
statRepo: statRepo,
websiteRepo: websiteRepo,
aggregator: aggregator,
}
}
// Overview 概览数据(汇总 + 时间序列 + 对比 + 站点列表)
func (s *WebsiteStatService) Overview(w http.ResponseWriter, r *http.Request) {
start := r.URL.Query().Get("start")
end := r.URL.Query().Get("end")
if start == "" || end == "" {
today := time.Now().Format(time.DateOnly)
start = today
end = today
}
// 解析站点过滤
var sites []string
if sitesParam := r.URL.Query().Get("sites"); sitesParam != "" {
sites = lo.Filter(strings.Split(sitesParam, ","), func(s string, _ int) bool {
return strings.TrimSpace(s) != ""
})
sites = lo.Map(sites, func(s string, _ int) string {
return strings.TrimSpace(s)
})
}
// 计算对比周期
startDate, _ := time.Parse(time.DateOnly, start)
endDate, _ := time.Parse(time.DateOnly, end)
duration := int(endDate.Sub(startDate).Hours()/24) + 1
prevEnd := startDate.AddDate(0, 0, -1).Format(time.DateOnly)
prevStart := startDate.AddDate(0, 0, -duration).Format(time.DateOnly)
isSingleDay := start == end
today := time.Now().Format(time.DateOnly)
// 查询当前周期汇总
current, err := s.queryTotals(start, end, sites, today)
if err != nil {
ErrorSystem(w)
return
}
// 查询对比周期汇总
previous, err := s.queryTotals(prevStart, prevEnd, sites, today)
if err != nil {
ErrorSystem(w)
return
}
// 查询时间序列
var series []*biz.WebsiteStatSeries
if isSingleDay {
series, err = s.queryHourlySeries(start, sites, today)
} else {
series, err = s.queryDailySeries(start, end, sites, today)
}
if err != nil {
ErrorSystem(w)
return
}
// 查询对比周期时间序列
var prevSeries []*biz.WebsiteStatSeries
prevIsSingleDay := prevStart == prevEnd
if prevIsSingleDay {
prevSeries, err = s.queryHourlySeries(prevStart, sites, today)
} else {
prevSeries, err = s.queryDailySeries(prevStart, prevEnd, sites, today)
}
if err != nil {
ErrorSystem(w)
return
}
// 获取所有网站列表(用于站点选择器)
websites, _, err := s.websiteRepo.List("all", 1, 10000)
if err != nil {
ErrorSystem(w)
return
}
siteList := lo.Map(websites, func(ws *biz.Website, _ int) chix.M {
return chix.M{"id": ws.ID, "name": ws.Name}
})
Success(w, chix.M{
"current": current,
"previous": previous,
"series": series,
"previous_series": prevSeries,
"sites": siteList,
})
}
// queryTotals 查询指定日期范围的汇总数据
// 今日数据从内存聚合器获取(避免与 DB 重复计算),历史数据从 DB 查询
func (s *WebsiteStatService) queryTotals(start, end string, sites []string, today string) (*statTotals, error) {
includeToday := start <= today && today <= end
var total statTotals
// DB 查询:排除今天(今天的数据从内存获取)
dbEnd := end
if includeToday {
yesterday := time.Now().AddDate(0, 0, -1).Format(time.DateOnly)
dbEnd = yesterday
}
if dbEnd >= start {
dbStats, err := s.statRepo.ListByDateRange(start, dbEnd, sites)
if err != nil {
return nil, err
}
for _, st := range dbStats {
total.PV += st.PV
total.UV += st.UV
total.IP += st.IP
total.Bandwidth += st.Bandwidth
total.Requests += st.Requests
total.Errors += st.Errors
total.Spiders += st.Spiders
}
}
// 今天的数据从内存获取
if includeToday {
s.mergeLiveTotals(&total, sites)
}
return &total, nil
}
// mergeLiveTotals 合并内存中今日实时数据到汇总
func (s *WebsiteStatService) mergeLiveTotals(total *statTotals, sites []string) {
liveStats := s.aggregator.SiteStats()
siteSet := make(map[string]struct{}, len(sites))
for _, name := range sites {
siteSet[name] = struct{}{}
}
for name, snap := range liveStats {
if len(sites) > 0 {
if _, ok := siteSet[name]; !ok {
continue
}
}
total.PV += snap.PV
total.UV += snap.UV
total.IP += snap.IP
total.Bandwidth += snap.Bandwidth
total.Requests += snap.Requests
total.Errors += snap.Errors
total.Spiders += snap.Spiders
}
}
// queryHourlySeries 查询小时级时间序列
func (s *WebsiteStatService) queryHourlySeries(date string, sites []string, today string) ([]*biz.WebsiteStatSeries, error) {
hourMap := make(map[int]*biz.WebsiteStatSeries, 24)
if date == today {
// 今天的小时数据从内存获取
liveStats := s.aggregator.SiteStats()
siteSet := make(map[string]struct{}, len(sites))
for _, name := range sites {
siteSet[name] = struct{}{}
}
for name, snap := range liveStats {
if len(sites) > 0 {
if _, ok := siteSet[name]; !ok {
continue
}
}
for h, hs := range snap.Hours {
if hs == nil {
continue
}
if existing, ok := hourMap[h]; ok {
existing.PV += hs.PV
existing.UV += hs.UV
existing.IP += hs.IP
existing.Bandwidth += hs.Bandwidth
existing.Requests += hs.Requests
existing.Errors += hs.Errors
existing.Spiders += hs.Spiders
} 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,
}
}
}
}
} else {
// 历史数据从 DB 查询
dbSeries, err := s.statRepo.HourlySeries(date, sites)
if err != nil {
return nil, err
}
for _, item := range dbSeries {
h, _ := strconv.Atoi(item.Key)
hourMap[h] = item
}
}
// 填充完整 24 小时
result := make([]*biz.WebsiteStatSeries, 24)
for h := range 24 {
key := strconv.Itoa(h)
if s, ok := hourMap[h]; ok {
result[h] = s
} else {
result[h] = &biz.WebsiteStatSeries{Key: key}
}
}
return result, nil
}
// queryDailySeries 查询日级时间序列
func (s *WebsiteStatService) queryDailySeries(start, end string, sites []string, today string) ([]*biz.WebsiteStatSeries, error) {
includeToday := start <= today && today <= end
// DB 查询排除今天
dbEnd := end
if includeToday {
yesterday := time.Now().AddDate(0, 0, -1).Format(time.DateOnly)
dbEnd = yesterday
}
dateMap := make(map[string]*biz.WebsiteStatSeries)
if dbEnd >= start {
dbSeries, err := s.statRepo.DailySeries(start, dbEnd, sites)
if err != nil {
return nil, err
}
for _, item := range dbSeries {
dateMap[item.Key] = item
}
}
// 今天的数据从内存获取
if includeToday {
liveStats := s.aggregator.SiteStats()
siteSet := make(map[string]struct{}, len(sites))
for _, name := range sites {
siteSet[name] = struct{}{}
}
todayData := &biz.WebsiteStatSeries{Key: today}
for name, snap := range liveStats {
if len(sites) > 0 {
if _, ok := siteSet[name]; !ok {
continue
}
}
todayData.PV += snap.PV
todayData.UV += snap.UV
todayData.IP += snap.IP
todayData.Bandwidth += snap.Bandwidth
todayData.Requests += snap.Requests
todayData.Errors += snap.Errors
todayData.Spiders += snap.Spiders
}
dateMap[today] = todayData
}
// 填充日期范围内所有天
startDate, _ := time.Parse(time.DateOnly, start)
endDate, _ := time.Parse(time.DateOnly, end)
var result []*biz.WebsiteStatSeries
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
key := d.Format(time.DateOnly)
if s, ok := dateMap[key]; ok {
result = append(result, s)
} else {
result = append(result, &biz.WebsiteStatSeries{Key: key})
}
}
return result, nil
}
// Realtime 实时流量/RPS
func (s *WebsiteStatService) Realtime(w http.ResponseWriter, r *http.Request) {
rt := s.aggregator.Realtime()
Success(w, rt)
}
// Clear 清空所有统计数据
func (s *WebsiteStatService) Clear(w http.ResponseWriter, r *http.Request) {
if err := s.statRepo.Clear(); err != nil {
ErrorSystem(w)
return
}
Success(w, nil)
}
// GetSetting 获取统计设置
func (s *WebsiteStatService) GetSetting(w http.ResponseWriter, r *http.Request) {
days, _ := s.setting.GetInt(biz.SettingKeyWebsiteStatDays, 30)
Success(w, chix.M{
"days": days,
})
}
// UpdateSetting 更新统计设置
func (s *WebsiteStatService) UpdateSetting(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.WebsiteStatSetting](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.setting.Set(biz.SettingKeyWebsiteStatDays, fmt.Sprintf("%d", req.Days)); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
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"`
}
+4 -3
View File
@@ -51,9 +51,10 @@ type WebsiteSetting struct {
Redirects []types.Redirect `json:"redirects"`
// 高级设置
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
BasicAuth map[string]string `json:"basic_auth"` // 基本认证配置
StatEnabled bool `json:"stat_enabled"` // 是否启用访问统计
RateLimit *types.RateLimit `json:"rate_limit"` // 限流限速配置
RealIP *types.RealIP `json:"real_ip"` // 真实 IP 配置
BasicAuth map[string]string `json:"basic_auth"` // 基本认证配置
// 自定义配置
CustomConfigs []WebsiteCustomConfig `json:"custom_configs"`
+230
View File
@@ -0,0 +1,230 @@
package websitestat
import (
"sync"
"sync/atomic"
"time"
)
// Aggregator 内存聚合器,将日志条目聚合为站点统计快照
type Aggregator struct {
mu sync.Mutex
sites map[string]*siteDay
date string // 当前日期 YYYY-MM-DD
// 错误日志缓冲
errBuf []*ErrorEntry
// 实时统计(滑动窗口 60 秒)
rtSlots [60]rtSlot
rtSec int64
}
type hourBucket struct {
pv, requests, errors, spiders, bandwidth uint64
ips map[string]struct{}
uvs map[string]struct{}
}
type siteDay struct {
pv, requests, errors, spiders, bandwidth uint64
ips map[string]struct{}
uvs map[string]struct{}
hours [24]*hourBucket
}
type rtSlot struct {
bandwidth uint64
requests uint64
}
// NewAggregator 创建聚合器实例
func NewAggregator() *Aggregator {
return &Aggregator{
sites: make(map[string]*siteDay),
date: time.Now().Format(time.DateOnly),
}
}
// Record 记录一条访问日志
func (a *Aggregator) Record(entry *LogEntry) {
now := time.Now()
today := now.Format(time.DateOnly)
hour := now.Hour()
a.mu.Lock()
// 日期切换,重置所有数据
if today != a.date {
a.sites = make(map[string]*siteDay)
a.date = today
}
sd, ok := a.sites[entry.Site]
if !ok {
sd = &siteDay{
ips: make(map[string]struct{}),
uvs: make(map[string]struct{}),
}
a.sites[entry.Site] = sd
}
// 确保小时桶已初始化
hb := sd.hours[hour]
if hb == nil {
hb = &hourBucket{
ips: make(map[string]struct{}),
uvs: make(map[string]struct{}),
}
sd.hours[hour] = hb
}
// 更新每日总计
sd.requests++
sd.bandwidth += entry.Bytes
// 更新小时桶
hb.requests++
hb.bandwidth += entry.Bytes
// UV: IP+UA 去重(每日 + 每小时)
uvKey := entry.IP + "|" + entry.UA
sd.uvs[uvKey] = struct{}{}
hb.uvs[uvKey] = struct{}{}
// IP 去重(每日 + 每小时)
sd.ips[entry.IP] = struct{}{}
hb.ips[entry.IP] = struct{}{}
// PV 判定
if IsPageView(entry) {
sd.pv++
hb.pv++
}
// 蜘蛛检测
if IsSpider(entry.UA) {
sd.spiders++
hb.spiders++
}
// 错误计数
if entry.Status >= 400 && entry.Status < 600 {
sd.errors++
hb.errors++
a.errBuf = append(a.errBuf, &ErrorEntry{
Site: entry.Site,
URI: entry.URI,
Method: entry.Method,
IP: entry.IP,
UA: entry.UA,
Body: entry.Body,
Status: entry.Status,
})
}
a.mu.Unlock()
// 实时统计(原子操作,无需持有 mu)
sec := now.Unix()
idx := sec % 60
curSec := atomic.LoadInt64(&a.rtSec)
if sec != curSec {
if atomic.CompareAndSwapInt64(&a.rtSec, curSec, sec) {
// 清零当前槽
atomic.StoreUint64(&a.rtSlots[idx].bandwidth, 0)
atomic.StoreUint64(&a.rtSlots[idx].requests, 0)
}
}
atomic.AddUint64(&a.rtSlots[idx].bandwidth, entry.Bytes)
atomic.AddUint64(&a.rtSlots[idx].requests, 1)
}
// Snapshot 返回当前日期和各站点快照(不重置数据)
func (a *Aggregator) Snapshot() (string, map[string]*SiteSnapshot) {
a.mu.Lock()
defer a.mu.Unlock()
date := a.date
result := make(map[string]*SiteSnapshot, len(a.sites))
for name, sd := range a.sites {
snap := &SiteSnapshot{
PV: sd.pv,
UV: uint64(len(sd.uvs)),
IP: uint64(len(sd.ips)),
Bandwidth: sd.bandwidth,
Requests: sd.requests,
Errors: sd.errors,
Spiders: sd.spiders,
}
// 填充小时快照
for h := 0; h < 24; h++ {
if hb := sd.hours[h]; hb != nil {
snap.Hours[h] = &HourSnapshot{
PV: hb.pv,
UV: uint64(len(hb.uvs)),
IP: uint64(len(hb.ips)),
Bandwidth: hb.bandwidth,
Requests: hb.requests,
Errors: hb.errors,
Spiders: hb.spiders,
}
}
}
result[name] = snap
}
return date, result
}
// SiteStats 返回各站点当前统计(不重置)
func (a *Aggregator) SiteStats() map[string]*SiteSnapshot {
_, stats := a.Snapshot()
return stats
}
// DrainErrors 取出并清空错误缓冲
func (a *Aggregator) DrainErrors() []*ErrorEntry {
a.mu.Lock()
defer a.mu.Unlock()
errors := a.errBuf
a.errBuf = nil
return errors
}
// Realtime 返回最近一段时间的实时流量和 RPS
func (a *Aggregator) Realtime() RealtimeStats {
lastSec := atomic.LoadInt64(&a.rtSec)
if lastSec == 0 {
return RealtimeStats{}
}
now := time.Now().Unix()
curIdx := now % 60
var totalBw, totalReq uint64
var count int64
// 统计最近 5 秒(排除当前秒)
for i := int64(1); i <= 5; i++ {
idx := (curIdx - i + 60) % 60
sec := now - i
// 只统计最近 60 秒内的有效数据
if sec >= lastSec-60 && sec <= lastSec {
totalBw += atomic.LoadUint64(&a.rtSlots[idx].bandwidth)
totalReq += atomic.LoadUint64(&a.rtSlots[idx].requests)
count++
}
}
if count == 0 {
return RealtimeStats{}
}
return RealtimeStats{
Bandwidth: float64(totalBw) / float64(count),
RPS: float64(totalReq) / float64(count),
}
}
+58
View File
@@ -0,0 +1,58 @@
package websitestat
import (
"fmt"
"log/slog"
"net"
"os"
)
// Listener 通过 Unix Datagram Socket 监听 nginx syslog 消息
type Listener struct {
path string
conn *net.UnixConn
log *slog.Logger
}
// NewListener 创建并绑定 Unix Datagram Socket 监听器
func NewListener(path string, log *slog.Logger) (*Listener, error) {
// 清理可能残留的旧 socket 文件
_ = os.Remove(path)
addr := &net.UnixAddr{Name: path, Net: "unixgram"}
conn, err := net.ListenUnixgram("unixgram", addr)
if err != nil {
return nil, fmt.Errorf("failed to listen on unix socket %s: %w", path, err)
}
// 确保 nginx 用户可写
if err = os.Chmod(path, 0666); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to chmod socket: %w", err)
}
return &Listener{
path: path,
conn: conn,
log: log,
}, nil
}
// Read 读取一条 syslog 消息,返回 tag 和 JSON 数据
func (l *Listener) Read() (string, []byte, error) {
buf := make([]byte, 65536)
n, err := l.conn.Read(buf)
if err != nil {
return "", nil, err
}
tag, data := ParseSyslog(buf[:n])
return tag, data, nil
}
// Close 关闭监听器并清理 socket 文件
func (l *Listener) Close() error {
err := l.conn.Close()
_ = os.Remove(l.path)
return err
}
+34
View File
@@ -0,0 +1,34 @@
package websitestat
import (
"path"
"strings"
)
// 静态资源扩展名(非页面浏览)
var staticExts = map[string]struct{}{
".js": {}, ".css": {}, ".png": {}, ".jpg": {}, ".jpeg": {},
".gif": {}, ".svg": {}, ".ico": {}, ".woff": {}, ".woff2": {},
".ttf": {}, ".eot": {}, ".otf": {}, ".map": {}, ".webp": {},
".avif": {}, ".mp4": {}, ".mp3": {}, ".webm": {}, ".ogg": {},
".pdf": {}, ".zip": {}, ".gz": {}, ".tar": {}, ".rar": {},
".7z": {}, ".bz2": {}, ".xz": {}, ".swf": {}, ".flv": {},
}
// IsPageView 判定请求是否为页面浏览(PV)
func IsPageView(entry *LogEntry) bool {
// 优先使用 Content-Type 判定
if entry.ContentType != "" {
return strings.Contains(entry.ContentType, "text/html")
}
// 回退到 URI 扩展名判定
ext := strings.ToLower(path.Ext(entry.URI))
// 无扩展名视为页面(如 /about, /api/xxx
if ext == "" {
return true
}
// 检查是否为已知静态资源
_, isStatic := staticExts[ext]
return !isStatic
}
+95
View File
@@ -0,0 +1,95 @@
package websitestat
import (
"encoding/json"
)
// ParseSyslog 从 syslog 消息中提取 tag 和 JSON 体
// 格式: <PRI>MMM DD HH:MM:SS tag: JSONnohostname 模式下无主机名)
func ParseSyslog(msg []byte) (string, []byte) {
n := len(msg)
if n == 0 {
return "", nil
}
i := 0
// 跳过 PRI: <xxx>
if msg[0] == '<' {
for i < n && msg[i] != '>' {
i++
}
if i < n {
i++ // 跳过 '>'
}
}
// 找到 ": " 分隔符,tag 在其前面的最后一个空格之后
colonIdx := -1
for j := i; j < n-1; j++ {
if msg[j] == ':' && msg[j+1] == ' ' {
colonIdx = j
break
}
}
if colonIdx < 0 {
return "", nil
}
// tag 是 ": " 前面最后一个空格之后的部分
tagStart := i
for j := colonIdx - 1; j >= i; j-- {
if msg[j] == ' ' {
tagStart = j + 1
break
}
}
tag := string(msg[tagStart:colonIdx])
data := msg[colonIdx+2:] // 跳过 ": "
return tag, data
}
// jsonEntry 内部 JSON 解析结构(与 nginx log_format 对应)
type jsonEntry struct {
URI string `json:"uri"`
Status json.Number `json:"status"`
Bytes json.Number `json:"bytes"`
UA string `json:"ua"`
IP string `json:"ip"`
Method string `json:"method"`
Body string `json:"body"`
ContentType string `json:"content_type"`
ReqLength json.Number `json:"req_length"`
}
// ParseLogEntry 将 JSON 数据解析为 LogEntry
func ParseLogEntry(tag string, data []byte) (*LogEntry, error) {
var je jsonEntry
if err := json.Unmarshal(data, &je); err != nil {
return nil, err
}
status, _ := je.Status.Int64()
bytes, _ := je.Bytes.Int64()
reqLen, _ := je.ReqLength.Int64()
entry := &LogEntry{
Site: tag,
URI: je.URI,
Status: int(status),
Bytes: uint64(bytes),
UA: je.UA,
IP: je.IP,
Method: je.Method,
ContentType: je.ContentType,
ReqLength: uint64(reqLen),
}
// 仅在 4xx/5xx 且请求体 <= 64KB 时保留 body
if status >= 400 && status < 600 && len(je.Body) <= 65536 {
entry.Body = je.Body
}
return entry, nil
}
+52
View File
@@ -0,0 +1,52 @@
package websitestat
import "strings"
// 已知搜索引擎蜘蛛 UA 关键词
var spiderKeywords = []string{
"googlebot",
"bingbot",
"baiduspider",
"yandexbot",
"sogou",
"360spider",
"bytespider",
"gptbot",
"claudebot",
"ahrefsbot",
"semrushbot",
"dotbot",
"mj12bot",
"petalbot",
"applebot",
"duckduckbot",
"slurp",
"ia_archiver",
"facebookexternalhit",
"twitterbot",
"rogerbot",
"linkedinbot",
"embedly",
"quora link preview",
"showyoubot",
"outbrain",
"pinterest",
"slackbot",
"vkshare",
"w3c_validator",
"redditbot",
"scrapy",
"curl",
"wget",
}
// IsSpider 检测 User-Agent 是否为已知蜘蛛
func IsSpider(ua string) bool {
lower := strings.ToLower(ua)
for _, kw := range spiderKeywords {
if strings.Contains(lower, kw) {
return true
}
}
return false
}
+55
View File
@@ -0,0 +1,55 @@
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 状态码且 <= 64KB 时保留)
ContentType string // 响应 Content-TypePV 判定用)
ReqLength uint64 // 请求大小(入站流量)
}
// 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"`
}
// 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:"-"`
}
// RealtimeStats 实时统计
type RealtimeStats struct {
Bandwidth float64 `json:"bandwidth"` // 字节/秒
RPS float64 `json:"rps"` // 请求/秒
}
// ErrorEntry 错误日志条目
type ErrorEntry struct {
Site string
URI string
Method string
IP string
UA string
Body string
Status int
}
+12 -1
View File
@@ -29,5 +29,16 @@ export default {
// 修改状态
status: (id: number, status: boolean): any => http.Post(`/website/${id}/status`, { status }),
// 签发证书
obtainCert: (id: number): any => http.Post(`/website/${id}/obtain_cert`)
obtainCert: (id: number): any => http.Post(`/website/${id}/obtain_cert`),
// 统计概览
statOverview: (start: string, end: string, sites?: string): any =>
http.Get('/website/stat/overview', { params: { start, end, sites } }),
// 实时统计
statRealtime: (): any => http.Get('/website/stat/realtime'),
// 清空统计
statClear: (): any => http.Post('/website/stat/clear'),
// 统计设置
statSetting: (): any => http.Get('/website/stat/setting'),
// 保存统计设置
saveStatSetting: (data: any): any => http.Post('/website/stat/setting', data)
}
+2 -2
View File
@@ -32,7 +32,7 @@ const typeOptions = computed(() => [
const createModel = ref({
type: '',
name: '',
listens: [''],
listens: ['80'],
domains: [''],
path: '',
db: false,
@@ -124,7 +124,7 @@ const handleCreate = async () => {
type: '',
name: '',
domains: [''],
listens: [''],
listens: ['80'],
db: false,
db_type: '0',
db_name: '',
+13
View File
@@ -1731,6 +1731,19 @@ const removeCustomConfig = (index: number) => {
</n-tab-pane>
<n-tab-pane name="advanced" :tab="$gettext('Advanced Settings')">
<n-collapse accordion>
<!-- 访问统计 nginx -->
<n-collapse-item
v-if="isNginx"
:title="$gettext('Access Statistics')"
name="stat_settings"
>
<n-form label-placement="left" label-width="140px">
<n-form-item :label="$gettext('Enable Statistics')">
<n-switch v-model:value="setting.stat_enabled" />
</n-form-item>
</n-form>
</n-collapse-item>
<!-- 日志设置 -->
<n-collapse-item :title="$gettext('Log Settings')" name="log_settings">
<n-form label-placement="left" label-width="140px">
+5 -2
View File
@@ -7,9 +7,10 @@ import BulkCreateModal from '@/views/website/BulkCreateModal.vue'
import CreateModal from '@/views/website/CreateModal.vue'
import EditModal from '@/views/website/EditModal.vue'
import ListView from '@/views/website/ListView.vue'
import StatsView from '@/views/website/StatsView.vue'
import SettingView from '@/views/website/SettingView.vue'
const currentTab = ref('all')
const currentTab = ref('stats')
const createModal = ref(false)
const bulkCreateModal = ref(false)
@@ -21,6 +22,7 @@ const editId = ref(0)
<common-page show-header show-footer>
<template #tabbar>
<n-tabs v-model:value="currentTab" animated>
<n-tab name="stats" :tab="$gettext('Stats')" />
<n-tab name="all" :tab="$gettext('All')" />
<n-tab name="proxy" :tab="$gettext('Reverse Proxy')" />
<n-tab name="php" :tab="$gettext('PHP')" />
@@ -28,8 +30,9 @@ const editId = ref(0)
<n-tab name="setting" :tab="$gettext('Settings')" />
</n-tabs>
</template>
<stats-view v-if="currentTab === 'stats'" />
<list-view
v-if="currentTab != 'setting'"
v-if="currentTab != 'setting' && currentTab != 'stats'"
v-model:type="currentTab"
v-model:create-modal="createModal"
v-model:bulk-create-modal="bulkCreateModal"
+48
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import website from '@/api/panel/website'
import home from '@/api/panel/home'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
@@ -17,6 +18,20 @@ const { data: model } = useRequest(website.defaultConfig, {
}
})
// 判断 webserver 类型
const isNginx = ref(false)
useRequest(home.installedEnvironment()).onSuccess(({ data }: any) => {
isNginx.value = data.webserver === 'nginx'
})
// 统计设置
const statSetting = ref({ days: 30 })
const statSaveLoading = ref(false)
useRequest(website.statSetting()).onSuccess(({ data }: any) => {
statSetting.value = data
})
watch(
() => model.value.tls_versions,
(newVal) => {
@@ -42,6 +57,17 @@ const handleSave = () => {
saveLoading.value = false
})
}
const handleSaveStatSetting = () => {
statSaveLoading.value = true
useRequest(website.saveStatSetting(statSetting.value))
.onSuccess(() => {
window.$message.success($gettext('Modified successfully'))
})
.onComplete(() => {
statSaveLoading.value = false
})
}
</script>
<template>
@@ -124,6 +150,28 @@ const handleSave = () => {
</n-form>
</n-flex>
</n-tab-pane>
<n-tab-pane v-if="isNginx" name="stat-setting" :tab="$gettext('Statistics')">
<n-flex vertical>
<n-form>
<n-form-item :label="$gettext('Data Retention Days')">
<n-input-number
v-model:value="statSetting.days"
:min="1"
:max="365"
style="width: 200px"
/>
</n-form-item>
<n-button
type="primary"
:loading="statSaveLoading"
:disabled="statSaveLoading"
@click="handleSaveStatSetting"
>
{{ $gettext('Save Changes') }}
</n-button>
</n-form>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
+427
View File
@@ -0,0 +1,427 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import VChart from 'vue-echarts'
import { useGettext } from 'vue3-gettext'
import website from '@/api/panel/website'
import { formatBytes } from '@/utils/file'
const { $gettext } = useGettext()
use([CanvasRenderer, LineChart, TooltipComponent, LegendComponent, GridComponent])
// ============ 时间预设 ============
type TimePreset = 'today' | 'yesterday' | '7d' | '30d' | 'custom'
const activePreset = ref<TimePreset>('today')
const customRange = ref<[number, number] | null>(null)
const customPopover = ref(false)
const tempRange = ref<[number, number] | null>(null)
function getDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const dateRange = computed<{ start: string; end: string }>(() => {
const now = new Date()
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (activePreset.value) {
case 'today':
return { start: getDateStr(todayDate), end: getDateStr(todayDate) }
case 'yesterday': {
const y = new Date(todayDate.getTime() - 86400000)
return { start: getDateStr(y), end: getDateStr(y) }
}
case '7d': {
const s = new Date(todayDate.getTime() - 6 * 86400000)
return { start: getDateStr(s), end: getDateStr(todayDate) }
}
case '30d': {
const s = new Date(todayDate.getTime() - 29 * 86400000)
return { start: getDateStr(s), end: getDateStr(todayDate) }
}
case 'custom': {
if (customRange.value) {
return {
start: getDateStr(new Date(customRange.value[0])),
end: getDateStr(new Date(customRange.value[1]))
}
}
return { start: getDateStr(todayDate), end: getDateStr(todayDate) }
}
default:
return { start: getDateStr(todayDate), end: getDateStr(todayDate) }
}
})
function setPreset(preset: TimePreset) {
activePreset.value = preset
}
function confirmCustom() {
if (tempRange.value) {
customRange.value = tempRange.value
activePreset.value = 'custom'
}
customPopover.value = false
}
// ============ 站点选择器 ============
const selectedSites = ref<string[]>([])
const sitesParam = computed(() => {
return selectedSites.value.length > 0 ? selectedSites.value.join(',') : ''
})
// ============ 数据加载 ============
interface StatTotals {
pv: number
uv: number
ip: number
bandwidth: number
requests: number
errors: number
spiders: number
}
interface SeriesPoint {
key: string
pv: number
uv: number
ip: number
bandwidth: number
requests: number
errors: number
spiders: number
}
interface OverviewData {
current: StatTotals
previous: StatTotals
series: SeriesPoint[]
previous_series: SeriesPoint[]
sites: Array<{ id: number; name: string }>
}
const emptyTotals: StatTotals = { pv: 0, uv: 0, ip: 0, bandwidth: 0, requests: 0, errors: 0, spiders: 0 }
const overview = ref<OverviewData>({
current: { ...emptyTotals },
previous: { ...emptyTotals },
series: [],
previous_series: [],
sites: []
})
const loading = ref(false)
const loadOverview = () => {
loading.value = true
useRequest(website.statOverview(dateRange.value.start, dateRange.value.end, sitesParam.value))
.onSuccess(({ data }: any) => {
overview.value = data
})
.onComplete(() => {
loading.value = false
})
}
// 实时数据
const realtime = ref({ bandwidth: 0, rps: 0 })
let pollTimer: ReturnType<typeof setInterval> | null = null
const loadRealtime = () => {
useRequest(website.statRealtime()).onSuccess(({ data }: any) => {
realtime.value = data
})
}
watch([dateRange, sitesParam], () => {
loadOverview()
})
onMounted(() => {
loadOverview()
loadRealtime()
pollTimer = setInterval(loadRealtime, 5000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
// ============ 清空数据 ============
const handleClear = () => {
useRequest(website.statClear()).onSuccess(() => {
window.$message.success($gettext('Cleared successfully'))
loadOverview()
})
}
// ============ 指标定义 ============
type MetricKey = 'pv' | 'uv' | 'ip' | 'bandwidth' | 'requests' | 'errors' | 'spiders'
const metrics: Array<{ key: MetricKey; label: string; isBytes?: boolean }> = [
{ key: 'pv', label: 'PV' },
{ key: 'uv', label: 'UV' },
{ key: 'ip', label: 'IP' },
{ key: 'bandwidth', label: $gettext('Bandwidth'), isBytes: true },
{ key: 'requests', label: $gettext('Requests') },
{ key: 'errors', label: $gettext('Errors') },
{ key: 'spiders', label: $gettext('Spiders') }
]
// ============ 统计卡片 ============
function formatValue(value: number, isBytes?: boolean): string {
if (isBytes) return formatBytes(value)
if (value >= 100000000) return (value / 1000000).toFixed(1) + 'M'
if (value >= 100000) return (value / 1000).toFixed(1) + 'K'
return String(value)
}
function formatDiff(current: number, previous: number): { text: string; type: 'up' | 'down' | 'same' } {
if (previous === 0) {
if (current === 0) return { text: '-', type: 'same' }
return { text: '+100%', type: 'up' }
}
const diff = ((current - previous) / previous) * 100
if (diff === 0) return { text: '0%', type: 'same' }
if (diff > 0) return { text: `+${diff.toFixed(1)}%`, type: 'up' }
return { text: `${diff.toFixed(1)}%`, type: 'down' }
}
// ============ 图表 ============
const activeMetric = ref<MetricKey>('pv')
const showPrevious = ref(true)
const isSingleDay = computed(() => dateRange.value.start === dateRange.value.end)
function formatXLabel(key: string): string {
if (isSingleDay.value) {
return `${key.padStart(2, '0')}:00`
}
// 日期 "2026-02-18" -> "02-18"
return key.slice(5)
}
const chartOption = computed<EChartsOption>(() => {
const series = overview.value.series || []
const prevSeries = overview.value.previous_series || []
const metric = activeMetric.value
const isBytes = metrics.find((m) => m.key === metric)?.isBytes
const xData = series.map((s) => formatXLabel(s.key))
const currentData = series.map((s) => s[metric] || 0)
const previousData = prevSeries.map((s) => s[metric] || 0)
const chartSeries: any[] = [
{
name: $gettext('Current Period'),
type: 'line',
smooth: true,
symbol: 'none',
areaStyle: { opacity: 0.15 },
lineStyle: { width: 2 },
data: currentData
}
]
if (showPrevious.value && previousData.length > 0) {
chartSeries.push({
name: $gettext('Previous Period'),
type: 'line',
smooth: true,
symbol: 'none',
lineStyle: { width: 1.5, type: 'dashed' },
areaStyle: { opacity: 0.05 },
data: previousData
})
}
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 val = isBytes ? formatBytes(p.value) : p.value.toLocaleString()
html += `<div>${p.marker} ${p.seriesName}: ${val}</div>`
}
html += '</div>'
return html
}
},
legend: {
top: 0,
right: 0
},
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: isBytes ? (v: number) => formatBytes(v) : undefined
},
splitLine: { lineStyle: { type: 'dashed' } }
},
series: chartSeries
}
})
// ============ 站点选择选项 ============
const siteOptions = computed(() => {
return (overview.value.sites || []).map((s) => ({
label: s.name,
value: s.name
}))
})
// ============ 对比周期标签 ============
const previousLabel = computed(() => {
if (activePreset.value === 'today') return $gettext('Yesterday')
if (activePreset.value === 'yesterday') return $gettext('Day Before Yesterday')
return $gettext('Previous Period')
})
</script>
<template>
<n-flex vertical :size="20">
<!-- 工具栏 -->
<div class="flex w-full flex-wrap items-center gap-12">
<!-- 站点选择器 -->
<n-select
v-model:value="selectedSites"
multiple
clearable
:options="siteOptions"
:placeholder="$gettext('All Sites')"
style="min-width: 200px; max-width: 400px"
/>
<!-- 时间预设 -->
<n-button-group size="small">
<n-button :type="activePreset === 'today' ? 'primary' : 'default'" @click="setPreset('today')">
{{ $gettext('Today') }}
</n-button>
<n-button :type="activePreset === 'yesterday' ? 'primary' : 'default'" @click="setPreset('yesterday')">
{{ $gettext('Yesterday') }}
</n-button>
<n-button :type="activePreset === '7d' ? 'primary' : 'default'" @click="setPreset('7d')">
{{ $gettext('Last 7 Days') }}
</n-button>
<n-button :type="activePreset === '30d' ? 'primary' : 'default'" @click="setPreset('30d')">
{{ $gettext('Last 30 Days') }}
</n-button>
<n-popover v-model:show="customPopover" trigger="click" placement="bottom-end" :show-arrow="false">
<template #trigger>
<n-button :type="activePreset === 'custom' ? 'primary' : 'default'">
{{ $gettext('Custom') }}
</n-button>
</template>
<n-date-picker
v-model:value="tempRange"
type="daterange"
panel
:default-time="['00:00:00', '23:59:59']"
:actions="['confirm']"
@confirm="confirmCustom"
/>
</n-popover>
</n-button-group>
<div class="ml-auto">
<n-popconfirm @positive-click="handleClear">
<template #trigger>
<n-button type="error" ghost size="small">
{{ $gettext('Clear Data') }}
</n-button>
</template>
{{ $gettext('Are you sure you want to clear all statistics data?') }}
</n-popconfirm>
</div>
</div>
<!-- 统计卡片 -->
<n-spin :show="loading">
<div class="grid grid-cols-3 gap-12 sm:grid-cols-5 lg:grid-cols-9">
<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">
<span :class="{ 'text-[var(--success-color)]': formatDiff(overview.current[m.key] || 0, overview.previous[m.key] || 0).type === 'up', 'text-[var(--error-color)]': formatDiff(overview.current[m.key] || 0, overview.previous[m.key] || 0).type === 'down', 'text-[var(--text-color-3)]': formatDiff(overview.current[m.key] || 0, overview.previous[m.key] || 0).type === 'same' }">
{{ formatDiff(overview.current[m.key] || 0, overview.previous[m.key] || 0).text }}
</span>
<span class="text-[var(--text-color-3)]">{{ previousLabel }}</span>
</div>
</div>
</n-card>
<n-card :bordered="false" size="small">
<div class="flex flex-col gap-4">
<span class="text-12px text-[var(--text-color-3)]">{{ $gettext('Realtime Bandwidth') }}</span>
<span class="text-20px font-bold">{{ formatBytes(realtime.bandwidth) }}/s</span>
</div>
</n-card>
<n-card :bordered="false" size="small">
<div class="flex flex-col gap-4">
<span class="text-12px text-[var(--text-color-3)]">RPS</span>
<span class="text-20px font-bold">{{ realtime.rps.toFixed(1) }}</span>
</div>
</n-card>
</div>
</n-spin>
<!-- 趋势图 -->
<n-card :bordered="false">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-8">
<!-- 指标选择 -->
<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 }}
</n-radio-button>
</n-radio-group>
<!-- 对比开关 -->
<n-checkbox v-model:checked="showPrevious">
{{ previousLabel }}
</n-checkbox>
</div>
</template>
<n-spin :show="loading">
<v-chart class="h-350px" :option="chartOption" autoresize />
</n-spin>
</n-card>
</n-flex>
</template>