feat(server): online session history with configurable stats window (#1243)

Add persisted online sessions, configurable retention and online history statistics with lifecycle, recovery, timezone and concurrency regression coverage.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
Sam5440andnaiba/CloudCode authored and GitHub committed 2026-10-16 19:25:13 +08:00
1 parent 9881c47f0c
commit 8721a15b12
22 files changed
+2242 -20

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// Get server online history
// @Summary Get server online history
// @Security BearerAuth
// @Schemes
// @Description Get online periods and aggregated uptime statistics of a specific server
// @Tags common
// @Param id path uint true "Server ID"
// @Param days query int false "Query window in days (default: configured online_stats_days, clamped to it)"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.OnlineHistoryResponse]
// @Router /server/{id}/online-history [get]
func getServerOnlineHistory(c *gin.Context) (*model.OnlineHistoryResponse, error) {
idStr := c.Param("id")
serverID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
server, ok := singleton.ServerShared.Get(serverID)
if !ok || server == nil {
return nil, singleton.Localizer.ErrorT("server not found")
}
if !userCanViewServer(c, server) {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
maxDays := singleton.Conf.OnlineHistoryRetentionDays()
if maxDays < model.MinOnlineStatsDays || maxDays > model.MaxOnlineStatsDays {
maxDays = model.DefaultOnlineStatsDays
}
days := maxDays
if daysStr := c.Query("days"); daysStr != "" {
d, err := strconv.Atoi(daysStr)
if err != nil || d < 1 {
return nil, singleton.Localizer.ErrorT("invalid days parameter")
}
// 查询窗口收敛到后台设置的统计范围内
if d > maxDays {
d = maxDays
}
days = d
}
if !isMember && days > 1 {
return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
}
now := time.Now()
windowEnd := now
windowStart := model.OnlineHistoryWindowStart(now, days, singleton.Loc)
sessions, err := singleton.OnlineHistoryShared.Sessions(serverID, windowStart, windowEnd)
if err != nil {
return nil, newGormError("%v", err)
}
periods := model.ClipOnlineSessions(sessions, windowStart, windowEnd)
daily := model.DailyOnlineBreakdown(periods, windowStart, windowEnd, singleton.Loc)
// 总时长取各日之和,与 daily[] 严格对账(每日累计原始时长后统一取整,
// 口径见 DailyOnlineBreakdown)
var totalSeconds uint64
for _, d := range daily {
totalSeconds += d.OnlineSeconds
}
windowSeconds := uint64(windowEnd.Sub(windowStart).Seconds())
onlineRate := 0.0
if windowSeconds > 0 {
onlineRate = float64(totalSeconds) / float64(windowSeconds) * 100
}
runtime := server.RuntimeSnapshot()
currentlyOnline := !runtime.LastActive.IsZero() && now.Sub(runtime.LastActive) <= model.OnlineActiveWindow
var lastActive *time.Time
if !runtime.LastActive.IsZero() {
lastActiveUTC := runtime.LastActive.UTC()
lastActive = &lastActiveUTC
}
// 响应统一以 UTC 序列化,避免窗口时间与库中读回的会话时间混用
// 本地偏移与 Z 两种写法(时段为值拷贝,不影响调用方)
utcPeriods := make([]model.OnlinePeriod, len(periods))
for i, p := range periods {
utcPeriods[i] = model.OnlinePeriod{Start: p.Start.UTC(), End: p.End.UTC(), Ongoing: p.Ongoing}
}
return &model.OnlineHistoryResponse{
ServerID: serverID,
ServerName: server.Name,
Days: days,
MaxDays: maxDays,
Timezone: singleton.Loc.String(),
TzOffset: tzOffsetSeconds(now, singleton.Loc),
WindowStart: windowStart.UTC(),
WindowEnd: windowEnd.UTC(),
TotalOnlineSeconds: totalSeconds,
OnlineRate: onlineRate,
CurrentlyOnline: currentlyOnline,
LastActive: lastActive,
Periods: utcPeriods,
Daily: daily,
}, nil
}
// tzOffsetSeconds 返回指定时区在给定时刻相对 UTC 的偏移秒数。
func tzOffsetSeconds(at time.Time, loc *time.Location) int {
_, offset := at.In(loc).Zone()
return offset
}