mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-01 14:55:12 +08:00
feat: 网站统计
This commit is contained in:
+6
-2
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -35,4 +35,5 @@ var ProviderSet = wire.NewSet(
|
||||
NewUserTokenRepo,
|
||||
NewWebHookRepo,
|
||||
NewWebsiteRepo,
|
||||
NewWebsiteStatRepo,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package request
|
||||
|
||||
// WebsiteStatSetting 网站统计设置
|
||||
type WebsiteStatSetting struct {
|
||||
Days uint `json:"days" validate:"required|min:1|max:365"`
|
||||
}
|
||||
+9
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -46,6 +46,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUserTokenService,
|
||||
NewWebHookService,
|
||||
NewWebsiteService,
|
||||
NewWebsiteStatService,
|
||||
NewToolboxNetworkService,
|
||||
NewToolboxSystemService,
|
||||
NewToolboxBenchmarkService,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package websitestat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// ParseSyslog 从 syslog 消息中提取 tag 和 JSON 体
|
||||
// 格式: <PRI>MMM DD HH:MM:SS tag: JSON(nohostname 模式下无主机名)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-Type(PV 判定用)
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user