mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-05 00:50:21 +00:00
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:
1 parent
9881c47f0c
commit
8721a15b12
22 files changed
+2242
-20
No files matched your search
@@ -79,6 +79,10 @@ type ConfigDashboard struct {
|
||||
IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔)
|
||||
|
||||
DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"`
|
||||
|
||||
// OnlineStatsDays 设备在线时间段统计的保留与查询窗口(天)。
|
||||
// 取值范围 [MinOnlineStatsDays, MaxOnlineStatsDays],默认 DefaultOnlineStatsDays。
|
||||
OnlineStatsDays int `koanf:"online_stats_days" json:"online_stats_days,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -232,6 +236,13 @@ func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
|
||||
c.JWTTimeout = 1
|
||||
}
|
||||
|
||||
if c.OnlineStatsDays == 0 {
|
||||
c.OnlineStatsDays = DefaultOnlineStatsDays
|
||||
} else if c.OnlineStatsDays < MinOnlineStatsDays || c.OnlineStatsDays > MaxOnlineStatsDays {
|
||||
log.Printf("NEZHA>> online_stats_days %d out of range [%d, %d], reset to %d", c.OnlineStatsDays, MinOnlineStatsDays, MaxOnlineStatsDays, DefaultOnlineStatsDays)
|
||||
c.OnlineStatsDays = DefaultOnlineStatsDays
|
||||
}
|
||||
|
||||
c.jwtIPChangeAllowed.Store(c.AllowJWTIPChange)
|
||||
|
||||
if c.AgentSecretKey == "" {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 在线统计窗口的取值边界(天)。后台管理端可设置统计范围,查询窗口不允许
|
||||
// 超过该设置,历史数据按该设置做保留清理。
|
||||
const (
|
||||
DefaultOnlineStatsDays = 7
|
||||
MinOnlineStatsDays = 7
|
||||
MaxOnlineStatsDays = 3650
|
||||
)
|
||||
|
||||
// OnlineActiveWindow 服务器状态上报距该窗口内视为在线。与 MCP server.list
|
||||
// 的 onlineWindow 口径保持一致;超过该窗口未上报的进行中会话由定时清扫关闭。
|
||||
const OnlineActiveWindow = 30 * time.Second
|
||||
|
||||
// OnlineSession 记录一台服务器的一段连续在线时间。Ongoing=true 表示会话
|
||||
// 仍在进行(此时 EndedAt 为零值);会话结束时写入 EndedAt 并置 Ongoing=false。
|
||||
// LastActiveAt 由上报侧节流持久化,用于 dashboard 异常停机后把进行中的会话
|
||||
// 结转到最后一次已知活跃时刻。
|
||||
type OnlineSession struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
|
||||
CreatedAt time.Time `gorm:"index;<-:create" json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
|
||||
ServerID uint64 `gorm:"index:idx_online_sessions_server_started;index:idx_online_sessions_server_closed,priority:1;index:idx_online_sessions_server_open,priority:1" json:"server_id,omitempty"`
|
||||
StartedAt time.Time `gorm:"index:idx_online_sessions_server_started;index:idx_online_sessions_server_open,priority:3" json:"started_at,omitempty"`
|
||||
EndedAt time.Time `gorm:"index;index:idx_online_sessions_server_closed,priority:3" json:"ended_at,omitempty"` // 会话结束时刻,进行中为零值
|
||||
// 上报侧节流持久化的最后活跃时刻(≥OnlineActiveWindow 间隔写一次)
|
||||
LastActiveAt time.Time `json:"last_active_at,omitempty"`
|
||||
Ongoing bool `gorm:"index:idx_online_sessions_server_closed,priority:2;index:idx_online_sessions_server_open,priority:2" json:"ongoing"`
|
||||
}
|
||||
|
||||
// BeforeSave keeps SQLite's lexicographic timestamp comparisons chronological.
|
||||
// Query parameters and map-based updates must use UTC as well.
|
||||
func (s *OnlineSession) BeforeSave(_ *gorm.DB) error {
|
||||
s.StartedAt = s.StartedAt.UTC()
|
||||
s.EndedAt = s.EndedAt.UTC()
|
||||
s.LastActiveAt = s.LastActiveAt.UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnlinePeriod 是裁剪到查询窗口后的在线时间段,供前端时间轴直接渲染。
|
||||
type OnlinePeriod struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Ongoing bool `json:"ongoing"`
|
||||
}
|
||||
|
||||
// DailyOnlineStat 是单个自然日(面板时区)在查询窗口内的在线秒数。
|
||||
// 窗口首尾为非整日时,只统计与窗口重叠的部分。
|
||||
type DailyOnlineStat struct {
|
||||
Date string `json:"date"`
|
||||
OnlineSeconds uint64 `json:"online_seconds"`
|
||||
}
|
||||
|
||||
// OnlineHistoryResponse 是单台服务器在线时间段统计的聚合结果。
|
||||
type OnlineHistoryResponse struct {
|
||||
ServerID uint64 `json:"server_id"`
|
||||
ServerName string `json:"server_name"`
|
||||
Days int `json:"days"`
|
||||
MaxDays int `json:"max_days"`
|
||||
// Daily 拆日所用面板时区:IANA 名与窗口末端相对 UTC 的偏移秒数,
|
||||
// 前端按同一时区渲染,保证跨时区访客的图形与数字一致
|
||||
Timezone string `json:"timezone"`
|
||||
TzOffset int `json:"tz_offset"`
|
||||
WindowStart time.Time `json:"window_start"`
|
||||
WindowEnd time.Time `json:"window_end"`
|
||||
TotalOnlineSeconds uint64 `json:"total_online_seconds"`
|
||||
OnlineRate float64 `json:"online_rate"`
|
||||
CurrentlyOnline bool `json:"currently_online"`
|
||||
// 从未上报过的服务器该值为 nil;指针避免把 Go 零值时间序列化成 0001-01-01
|
||||
LastActive *time.Time `json:"last_active,omitempty"`
|
||||
Periods []OnlinePeriod `json:"periods"`
|
||||
Daily []DailyOnlineStat `json:"daily"`
|
||||
}
|
||||
|
||||
// ClipOnlineSessions 把与窗口 [windowStart, windowEnd) 相交的会话裁剪成
|
||||
// OnlinePeriod 列表。进行中的会话以 windowEnd 为右边界(保留 Ongoing 标记)。
|
||||
// 输入需按 StartedAt 升序;输出与输入同序。
|
||||
func ClipOnlineSessions(sessions []OnlineSession, windowStart, windowEnd time.Time) []OnlinePeriod {
|
||||
periods := make([]OnlinePeriod, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
start := s.StartedAt
|
||||
if start.Before(windowStart) {
|
||||
start = windowStart
|
||||
}
|
||||
end := s.EndedAt
|
||||
ongoing := s.Ongoing || end.IsZero()
|
||||
if ongoing || end.After(windowEnd) {
|
||||
end = windowEnd
|
||||
}
|
||||
if !start.Before(end) {
|
||||
// 与窗口零交集(理论上不会由查询条件选出)或空会话
|
||||
continue
|
||||
}
|
||||
periods = append(periods, OnlinePeriod{Start: start, End: end, Ongoing: ongoing})
|
||||
}
|
||||
return periods
|
||||
}
|
||||
|
||||
// DailyOnlineBreakdown 把在线时间段按面板时区的自然日拆分,返回窗口覆盖的
|
||||
// 每一天的在线秒数(含在线为 0 秒的日期),按日期升序。
|
||||
// 取整口径:每天先累计原始时长(日界切分不损失精度),仅在每日汇总时向下
|
||||
// 取整一次;调用方以各日之和作为总时长,保证响应内部 total 与 daily[] 严格
|
||||
// 对账,不与"逐段截断再累加"的口径混用(亚秒会话会让后者系统性偏小)。
|
||||
// 时段二分定位起始日界后逐日推进,复杂度 O(P·log D + D);单服务器的会话
|
||||
// 互不重叠,内层推进总量受窗口天数约束,长窗口(如 days=3650)下不会出现
|
||||
// 时段数×天数的双重循环。
|
||||
func DailyOnlineBreakdown(periods []OnlinePeriod, windowStart, windowEnd time.Time, loc *time.Location) []DailyOnlineStat {
|
||||
first := windowStart.In(loc)
|
||||
// Advance a timezone-independent calendar, not a normalized local midnight:
|
||||
// midnight DST gaps can normalize into the previous day and repeat dates.
|
||||
date := time.Date(first.Year(), first.Month(), first.Day(), 0, 0, 0, 0, time.UTC)
|
||||
var days []DailyOnlineStat
|
||||
var dayStarts, dayEnds []time.Time
|
||||
for {
|
||||
start := onlineCalendarBoundary(date, loc)
|
||||
if !start.Before(windowEnd) {
|
||||
break
|
||||
}
|
||||
nextDate := date.AddDate(0, 0, 1)
|
||||
end := onlineCalendarBoundary(nextDate, loc)
|
||||
if start.Before(end) {
|
||||
dayStarts = append(dayStarts, start)
|
||||
dayEnds = append(dayEnds, end)
|
||||
days = append(days, DailyOnlineStat{Date: date.Format("2006-01-02")})
|
||||
}
|
||||
date = nextDate
|
||||
}
|
||||
|
||||
if len(days) == 0 {
|
||||
return days
|
||||
}
|
||||
|
||||
dayDurations := make([]time.Duration, len(days))
|
||||
for _, p := range periods {
|
||||
// 该时段覆盖的第一天:不晚于时段开始的最后一个日界
|
||||
i := sort.Search(len(dayStarts), func(i int) bool {
|
||||
return dayStarts[i].After(p.Start)
|
||||
}) - 1
|
||||
if i < 0 {
|
||||
i = 0
|
||||
}
|
||||
// 逐日推进,累加该时段与每一天 [dayStart, nextDay) 的交集
|
||||
for ; i < len(dayStarts) && dayStarts[i].Before(p.End); i++ {
|
||||
overlapStart := maxTime(p.Start, dayStarts[i])
|
||||
overlapEnd := minTime(p.End, dayEnds[i])
|
||||
if overlapStart.Before(overlapEnd) {
|
||||
dayDurations[i] += overlapEnd.Sub(overlapStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range days {
|
||||
days[i].OnlineSeconds = uint64(dayDurations[i].Seconds())
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func maxTime(a, b time.Time) time.Time {
|
||||
if a.After(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func minTime(a, b time.Time) time.Time {
|
||||
if a.Before(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// A missing midnight (or an entirely skipped date) starts at the timezone
|
||||
// transition, not at the previous date returned by time.Date normalization.
|
||||
func onlineCalendarBoundary(date time.Time, loc *time.Location) time.Time {
|
||||
boundary := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
|
||||
if boundary.Format("2006-01-02") < date.Format("2006-01-02") {
|
||||
_, end := boundary.ZoneBounds()
|
||||
if !end.IsZero() {
|
||||
return end
|
||||
}
|
||||
}
|
||||
// time.Date may choose the second midnight in a rollback. Recover the
|
||||
// earlier occurrence using the offset on the preceding side of the transition.
|
||||
zoneStart, _ := boundary.ZoneBounds()
|
||||
if !zoneStart.IsZero() {
|
||||
_, currentOffset := boundary.Zone()
|
||||
_, previousOffset := zoneStart.Add(-time.Nanosecond).Zone()
|
||||
if previousOffset > currentOffset {
|
||||
earlier := boundary.Add(-time.Duration(previousOffset-currentOffset) * time.Second)
|
||||
if earlier.Format("2006-01-02 15:04:05") == boundary.Format("2006-01-02 15:04:05") {
|
||||
boundary = earlier
|
||||
}
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
// OnlineHistoryWindowStart defines the shared query and retention calendar window.
|
||||
func OnlineHistoryWindowStart(now time.Time, days int, loc *time.Location) time.Time {
|
||||
// Calendar days follow the dashboard timezone, not the host timezone;
|
||||
// DST days can contain 23 or 25 hours. Queries and pruning must agree.
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
return now.In(loc).AddDate(0, 0, -days).UTC()
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testZone = time.FixedZone("TEST", 8*3600)
|
||||
|
||||
func ts(y int, mo time.Month, d, h, mi, s int) time.Time {
|
||||
return time.Date(y, mo, d, h, mi, s, 0, testZone)
|
||||
}
|
||||
|
||||
func TestClipOnlineSessions(t *testing.T) {
|
||||
windowStart := ts(2026, 10, 1, 0, 0, 0)
|
||||
windowEnd := ts(2026, 10, 8, 0, 0, 0)
|
||||
|
||||
t.Run("session fully inside window", func(t *testing.T) {
|
||||
sessions := []OnlineSession{
|
||||
{ServerID: 1, StartedAt: ts(2026, 10, 2, 8, 0, 0), EndedAt: ts(2026, 10, 2, 9, 30, 0)},
|
||||
}
|
||||
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||
require.Len(t, periods, 1)
|
||||
require.Equal(t, ts(2026, 10, 2, 8, 0, 0), periods[0].Start)
|
||||
require.Equal(t, ts(2026, 10, 2, 9, 30, 0), periods[0].End)
|
||||
require.False(t, periods[0].Ongoing)
|
||||
})
|
||||
|
||||
t.Run("session started before window is clipped", func(t *testing.T) {
|
||||
sessions := []OnlineSession{
|
||||
{ServerID: 1, StartedAt: ts(2026, 9, 28, 12, 0, 0), EndedAt: ts(2026, 10, 1, 5, 0, 0)},
|
||||
}
|
||||
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||
require.Len(t, periods, 1)
|
||||
require.Equal(t, windowStart, periods[0].Start)
|
||||
require.Equal(t, ts(2026, 10, 1, 5, 0, 0), periods[0].End)
|
||||
})
|
||||
|
||||
t.Run("ongoing session ends at window end", func(t *testing.T) {
|
||||
sessions := []OnlineSession{
|
||||
{ServerID: 1, StartedAt: ts(2026, 10, 7, 10, 0, 0), Ongoing: true},
|
||||
}
|
||||
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||
require.Len(t, periods, 1)
|
||||
require.Equal(t, windowEnd, periods[0].End)
|
||||
require.True(t, periods[0].Ongoing)
|
||||
})
|
||||
|
||||
t.Run("zero ended_at treated as ongoing", func(t *testing.T) {
|
||||
sessions := []OnlineSession{
|
||||
{ServerID: 1, StartedAt: ts(2026, 10, 7, 10, 0, 0)},
|
||||
}
|
||||
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||
require.Len(t, periods, 1)
|
||||
require.True(t, periods[0].Ongoing)
|
||||
require.Equal(t, windowEnd, periods[0].End)
|
||||
})
|
||||
|
||||
t.Run("session outside window yields no period", func(t *testing.T) {
|
||||
sessions := []OnlineSession{
|
||||
{ServerID: 1, StartedAt: ts(2026, 9, 1, 0, 0, 0), EndedAt: ts(2026, 9, 2, 0, 0, 0)},
|
||||
}
|
||||
require.Empty(t, ClipOnlineSessions(sessions, windowStart, windowEnd))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDailyOnlineBreakdown(t *testing.T) {
|
||||
loc := time.FixedZone("UTC+8", 8*3600)
|
||||
windowStart := time.Date(2026, 10, 1, 10, 0, 0, 0, loc)
|
||||
windowEnd := time.Date(2026, 10, 3, 14, 0, 0, 0, loc)
|
||||
|
||||
periods := []OnlinePeriod{
|
||||
// 跨午夜:2 日 23:00 ~ 3 日 01:00
|
||||
{Start: time.Date(2026, 10, 2, 23, 0, 0, 0, loc), End: time.Date(2026, 10, 3, 1, 0, 0, 0, loc)},
|
||||
// 窗口前就开始的会话,裁剪后从窗口起点开始
|
||||
{Start: windowStart, End: time.Date(2026, 10, 1, 12, 0, 0, 0, loc)},
|
||||
}
|
||||
|
||||
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
|
||||
require.Len(t, daily, 3)
|
||||
require.Equal(t, "2026-10-01", daily[0].Date)
|
||||
require.Equal(t, "2026-10-02", daily[1].Date)
|
||||
require.Equal(t, "2026-10-03", daily[2].Date)
|
||||
|
||||
// 1 日:12:00-10:00 = 2h;2 日:23:00-24:00 = 1h;3 日:0:00-1:00 + 窗口外无 = 1h
|
||||
require.Equal(t, uint64(7200), daily[0].OnlineSeconds)
|
||||
require.Equal(t, uint64(3600), daily[1].OnlineSeconds)
|
||||
require.Equal(t, uint64(3600), daily[2].OnlineSeconds)
|
||||
|
||||
// 每日合计即总时长(口径:每日累计原始时长后统一取整)
|
||||
var sum uint64
|
||||
for _, d := range daily {
|
||||
sum += d.OnlineSeconds
|
||||
}
|
||||
require.Equal(t, uint64(7200+3600+3600), sum)
|
||||
}
|
||||
|
||||
// 评审回归:取整必须按日累计原始时长后统一转换。逐段截断再累加会让亚秒
|
||||
// 会话系统性偏小——同一天两个 1.5s 的会话应得 3s,而不是 1+1=2s
|
||||
func TestDailyOnlineBreakdownSubSecond(t *testing.T) {
|
||||
loc := testZone
|
||||
windowStart := ts(2026, 10, 1, 0, 0, 0)
|
||||
windowEnd := ts(2026, 10, 2, 0, 0, 0)
|
||||
periods := []OnlinePeriod{
|
||||
{Start: windowStart.Add(100 * time.Millisecond), End: windowStart.Add(1600 * time.Millisecond)},
|
||||
{Start: windowStart.Add(2 * time.Second), End: windowStart.Add(3500 * time.Millisecond)},
|
||||
}
|
||||
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
|
||||
require.Len(t, daily, 1)
|
||||
require.Equal(t, uint64(3), daily[0].OnlineSeconds)
|
||||
}
|
||||
|
||||
// 跨日时段按日界切分不损失精度:各日分别累计原始时长后各自取整
|
||||
func TestDailyOnlineBreakdownCrossDayFraction(t *testing.T) {
|
||||
loc := testZone
|
||||
windowStart := ts(2026, 10, 1, 0, 0, 0)
|
||||
windowEnd := ts(2026, 10, 3, 0, 0, 0)
|
||||
periods := []OnlinePeriod{{
|
||||
Start: time.Date(2026, 10, 1, 23, 59, 59, 250000000, loc),
|
||||
End: time.Date(2026, 10, 2, 0, 0, 2, 750000000, loc),
|
||||
}}
|
||||
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
|
||||
require.Len(t, daily, 2)
|
||||
require.Equal(t, uint64(0), daily[0].OnlineSeconds) // 0.75s → 0
|
||||
require.Equal(t, uint64(2), daily[1].OnlineSeconds) // 2.75s → 2
|
||||
}
|
||||
|
||||
func TestDailyOnlineBreakdownZeroOnline(t *testing.T) {
|
||||
loc := time.FixedZone("UTC+8", 8*3600)
|
||||
windowStart := time.Date(2026, 10, 1, 0, 0, 0, 0, loc)
|
||||
windowEnd := time.Date(2026, 10, 4, 0, 0, 0, 0, loc)
|
||||
daily := DailyOnlineBreakdown(nil, windowStart, windowEnd, loc)
|
||||
require.Len(t, daily, 3)
|
||||
for _, d := range daily {
|
||||
require.Zero(t, d.OnlineSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineStatsDaysBounds(t *testing.T) {
|
||||
require.Equal(t, 7, DefaultOnlineStatsDays)
|
||||
require.Equal(t, 7, MinOnlineStatsDays)
|
||||
require.Equal(t, 3650, MaxOnlineStatsDays)
|
||||
}
|
||||
|
||||
// F1 回归保护:指针推进实现必须与逐日朴素实现对任意不相交时段集合等价
|
||||
// (单服务器会话互不重叠的约束一致),长窗口大量时段下不再随时段数平方放大。
|
||||
func TestDailyOnlineBreakdownEquivalence(t *testing.T) {
|
||||
loc := testZone
|
||||
windowStart := ts(2026, 1, 1, 0, 0, 0)
|
||||
windowEnd := windowStart.AddDate(0, 0, 90)
|
||||
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
var periods []OnlinePeriod
|
||||
cursor := windowStart
|
||||
for cursor.Before(windowEnd) {
|
||||
// 起止带亚秒抖动:让逐段截断与按日累计两种口径可区分
|
||||
start := cursor.Add(time.Duration(rng.Intn(4*3600))*time.Second + time.Duration(rng.Int63n(int64(time.Second))))
|
||||
end := start.Add(time.Duration(1+rng.Intn(6*3600))*time.Second + time.Duration(rng.Int63n(int64(time.Second))))
|
||||
if end.After(windowEnd) {
|
||||
end = windowEnd
|
||||
}
|
||||
periods = append(periods, OnlinePeriod{Start: start, End: end})
|
||||
cursor = end.Add(time.Duration(1+rng.Intn(3600)) * time.Second)
|
||||
}
|
||||
|
||||
days := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
|
||||
|
||||
// 参考实现与生产同一口径:每日累计原始时长、汇总时向下取整一次
|
||||
reference := map[string]time.Duration{}
|
||||
for _, p := range periods {
|
||||
for d := windowStart; d.Before(windowEnd); d = d.AddDate(0, 0, 1) {
|
||||
overlapStart := maxTime(p.Start, d)
|
||||
overlapEnd := minTime(p.End, d.AddDate(0, 0, 1))
|
||||
if overlapStart.Before(overlapEnd) {
|
||||
reference[d.Format("2006-01-02")] += overlapEnd.Sub(overlapStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
require.Len(t, days, 90)
|
||||
for _, day := range days {
|
||||
require.Equal(t, uint64(reference[day.Date].Seconds()), day.OnlineSeconds, "date %s", day.Date)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyOnlineBreakdownMidnightDSTTransition(t *testing.T) {
|
||||
loc, err := time.LoadLocation("America/Sao_Paulo")
|
||||
require.NoError(t, err)
|
||||
start := time.Date(2018, 11, 3, 0, 0, 0, 0, loc)
|
||||
end := time.Date(2018, 11, 6, 0, 0, 0, 0, loc)
|
||||
days := DailyOnlineBreakdown([]OnlinePeriod{{Start: start, End: end}}, start, end, loc)
|
||||
require.Equal(t, []DailyOnlineStat{{Date: "2018-11-03", OnlineSeconds: 86400}, {Date: "2018-11-04", OnlineSeconds: 82800}, {Date: "2018-11-05", OnlineSeconds: 86400}}, days)
|
||||
}
|
||||
|
||||
func TestDailyOnlineBreakdownRepeatedMidnight(t *testing.T) {
|
||||
loc, err := time.LoadLocation("Asia/Amman")
|
||||
require.NoError(t, err)
|
||||
start := time.Date(2021, 10, 28, 21, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2021, 10, 29, 22, 0, 0, 0, time.UTC)
|
||||
days := DailyOnlineBreakdown([]OnlinePeriod{{Start: start, End: end}}, start, end, loc)
|
||||
require.Equal(t, []DailyOnlineStat{{Date: "2021-10-29", OnlineSeconds: 90000}}, days)
|
||||
}
|
||||
|
||||
func TestOnlineHistoryWindowUsesDashboardCalendar(t *testing.T) {
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
require.NoError(t, err)
|
||||
for _, test := range []struct {
|
||||
month time.Month
|
||||
day, hours int
|
||||
}{{time.March, 9, 23}, {time.November, 2, 25}} {
|
||||
end := time.Date(2026, test.month, test.day, 0, 30, 0, 0, loc)
|
||||
start := OnlineHistoryWindowStart(end.UTC(), 1, loc)
|
||||
require.True(t, start.Equal(time.Date(2026, test.month, test.day-1, 0, 30, 0, 0, loc)))
|
||||
require.Equal(t, time.Duration(test.hours)*time.Hour, end.Sub(start))
|
||||
require.Len(t, DailyOnlineBreakdown(nil, start, end, loc), 2)
|
||||
require.True(t, OnlineHistoryWindowStart(end.UTC(), 7, loc).Equal(end.In(loc).AddDate(0, 0, -7)))
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -211,10 +211,16 @@ func (lease StateStreamLease) updateState(receiver *Server, state *HostState, la
|
||||
}
|
||||
|
||||
func (lease StateStreamLease) Clear() bool {
|
||||
return lease.clear(nil)
|
||||
return lease.clear(nil, nil)
|
||||
}
|
||||
|
||||
func (lease StateStreamLease) clear(receiver *Server) bool {
|
||||
// ClearWithSideEffect serializes disconnect persistence with replacement streams.
|
||||
// The callback must not acquire the runtime holder again.
|
||||
func (lease StateStreamLease) ClearWithSideEffect(sideEffect func()) bool {
|
||||
return lease.clear(nil, sideEffect)
|
||||
}
|
||||
|
||||
func (lease StateStreamLease) clear(receiver *Server, sideEffect func()) bool {
|
||||
if lease.holder == nil {
|
||||
return false
|
||||
}
|
||||
@@ -226,6 +232,9 @@ func (lease StateStreamLease) clear(receiver *Server) bool {
|
||||
lease.holder.stream = nil
|
||||
lease.holder.lastActive = time.Time{}
|
||||
lease.holder.canonical.LastActive = time.Time{}
|
||||
if sideEffect != nil {
|
||||
sideEffect()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -329,7 +338,7 @@ func (s *Server) UpdateStateIfCurrentWithSideEffect(lease StateStreamLease, stat
|
||||
}
|
||||
|
||||
func (s *Server) ClearStateStreamIfCurrent(lease StateStreamLease) bool {
|
||||
return lease.clear(s)
|
||||
return lease.clear(s, nil)
|
||||
}
|
||||
|
||||
// RuntimeSnapshot is a deep copy of the mutable runtime state.
|
||||
|
||||
@@ -367,3 +367,30 @@ func TestServerRuntimeOwnership_failedRestartAllowsHourlyRecordThenRetry(t *test
|
||||
require.Equal(t, uint64(0), result.Transfer.In)
|
||||
require.Equal(t, uint64(0), result.Transfer.Out)
|
||||
}
|
||||
|
||||
func TestStateStreamDisconnectSideEffectSerializesReplacement(t *testing.T) {
|
||||
server := &Server{}
|
||||
old := server.AttachStateStream(runtimeOwnershipStream{})
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
cleared := make(chan bool, 1)
|
||||
go func() { cleared <- old.ClearWithSideEffect(func() { close(entered); <-release }) }()
|
||||
<-entered
|
||||
attached := make(chan StateStreamLease, 1)
|
||||
go func() { attached <- server.AttachStateStream(runtimeOwnershipStream{}) }()
|
||||
select {
|
||||
case <-attached:
|
||||
close(release)
|
||||
t.Fatal("replacement attached before disconnect persistence finished")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
require.True(t, <-cleared)
|
||||
next := <-attached
|
||||
require.True(t, next.UpdateState(&HostState{}, time.Now()))
|
||||
called := false
|
||||
require.False(t, old.ClearWithSideEffect(func() { called = true }))
|
||||
require.False(t, called)
|
||||
require.False(t, old.UpdateStateWithSideEffect(&HostState{}, time.Now(), func() error { called = true; return nil }))
|
||||
require.False(t, called)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ type SettingForm struct {
|
||||
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"`
|
||||
AllowJWTIPChange *bool `json:"allow_jwt_ip_change,omitempty" validate:"optional"`
|
||||
EnableMCP *bool `json:"enable_mcp,omitempty" validate:"optional"`
|
||||
|
||||
OnlineStatsDays *int `json:"online_stats_days,omitempty" validate:"optional"` // 在线统计范围(天),nil=保留现值
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
|
||||
Reference in new issue
Block a user