Merge upstream monitoring features preserving domain extensions and require fixed Go toolchain

This commit is contained in:
Codex committed 2026-10-22 23:50:03 +08:00
commit 71dd21269b
92 files changed
+8557 -874

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
package model
import (
"math"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestAlertEvaluationDistinguishesUnknownFromBreach(t *testing.T) {
server := &Server{Common: Common{ID: 1}}
rule := &Rule{Type: "cpu", Max: 80, Cover: RuleCoverAll}
if passed, known := rule.Evaluate(nil, server, nil); !passed || known {
t.Fatalf("missing state = (%v, %v), want unknown", passed, known)
}
lease := server.AttachStateStream(runtimeOwnershipStream{})
if !lease.UpdateState(&HostState{CPU: 99}, time.Now()) {
t.Fatal("state update rejected")
}
if passed, known := rule.Evaluate(nil, server, nil); passed || !known {
t.Fatalf("fresh high CPU = (%v, %v), want known breach", passed, known)
}
if !lease.Clear() {
t.Fatal("stream clear rejected")
}
// The raw metric remains available for historical display. The sentinel
// enforces freshness and must not count this value after disconnect.
if !server.RuntimeSnapshot().LastActive.IsZero() {
t.Fatal("disconnect did not clear online visibility")
}
}
func newCycleTestDB(t *testing.T, migrate bool) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if migrate {
if err := db.AutoMigrate(&Transfer{}); err != nil {
t.Fatal(err)
}
}
return db
}
func newCycleTestStats() *CycleTransferStats {
return &CycleTransferStats{
ServerName: make(map[uint64]string),
Transfer: make(map[uint64]uint64),
NextUpdate: make(map[uint64]time.Time),
}
}
func TestCycleRuleRejectsInvalidConfiguration(t *testing.T) {
start := time.Now().Add(-time.Hour)
rule := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Max: 100}
if !rule.HasSafeCycleConfiguration() {
t.Fatal("normal hourly cycle rejected")
}
for _, tc := range []struct {
name string
mutate func(*Rule)
}{
{"unknown unit", func(r *Rule) { r.CycleUnit = "fortnight" }},
{"negative limit", func(r *Rule) { r.Max = -1 }},
{"NaN limit", func(r *Rule) { r.Max = math.NaN() }},
{"reversed limits", func(r *Rule) { r.Min, r.Max = 200, 100 }},
{"future start", func(r *Rule) { future := time.Now().Add(time.Hour); r.CycleStart = &future }},
} {
t.Run(tc.name, func(t *testing.T) {
copy := *rule
tc.mutate(&copy)
if copy.HasSafeCycleConfiguration() {
t.Fatal("unsafe cycle accepted")
}
})
}
}
func TestCycleBoundsUseOneClockAtBoundary(t *testing.T) {
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
rule := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Max: 100}
before := start.Add(59*time.Minute + 59*time.Second)
from, to := rule.transferDurationBounds(before)
if !from.Equal(start) || !to.Equal(start.Add(time.Hour)) {
t.Fatalf("before boundary = %v..%v", from, to)
}
from, to = rule.transferDurationBounds(start.Add(time.Hour))
if !from.Equal(start.Add(time.Hour)) || !to.Equal(start.Add(2*time.Hour)) {
t.Fatalf("at boundary = %v..%v", from, to)
}
}
func TestCycleQueryFailureIsUnknownAndDoesNotCache(t *testing.T) {
start := time.Now().Add(-time.Hour)
rule := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Max: 50}
server := &Server{Common: Common{ID: 1}, State: &HostState{}}
passed, known := rule.Evaluate(newCycleTestStats(), server, newCycleTestDB(t, false))
if !passed || known || len(rule.NextTransferAt) != 0 {
t.Fatalf("SQL error yielded passed=%v known=%v cache=%v", passed, known, rule.NextTransferAt)
}
}
func TestCycleQueryExcludesFutureRowsAndHandlesMinOnly(t *testing.T) {
start := time.Now().Add(-90 * time.Minute)
db := newCycleTestDB(t, true)
server := &Server{Common: Common{ID: 1}, State: &HostState{}}
for _, row := range []Transfer{
{Common: Common{CreatedAt: time.Now().Add(-time.Minute)}, ServerID: 1, Out: 10},
{Common: Common{CreatedAt: time.Now().Add(10 * time.Minute)}, ServerID: 1, Out: 100},
} {
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
rule := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Max: 50}
stats := newCycleTestStats()
passed, known := rule.Evaluate(stats, server, db)
if !passed || !known || stats.Transfer[1] != 10 {
t.Fatalf("future row included: passed=%v known=%v amount=%d", passed, known, stats.Transfer[1])
}
minOnly := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Min: 20}
passed, known = minOnly.Evaluate(newCycleTestStats(), server, db)
if passed || !known {
t.Fatalf("min-only cycle = (%v,%v), want known breach", passed, known)
}
}
func TestAlertEvaluationAllNetworkSpeedUsesBothDirections(t *testing.T) {
for _, tc := range []struct {
name string
in, out uint64
breach bool
}{
{"inbound only", 100, 0, true},
{"outbound below combined limit", 0, 30, false},
{"both directions", 25, 30, true},
} {
t.Run(tc.name, func(t *testing.T) {
server := &Server{Common: Common{ID: 1}, State: &HostState{NetInSpeed: tc.in, NetOutSpeed: tc.out}}
rule := &Rule{Type: "net_all_speed", Max: 50, Cover: RuleCoverAll}
passed, known := rule.Evaluate(nil, server, nil)
if !known || passed == tc.breach {
t.Fatalf("got passed=%v known=%v, want breach=%v", passed, known, tc.breach)
}
})
}
}
func TestAlertEvaluationUnavailableSensorsAreUnknown(t *testing.T) {
tests := []struct {
name string
rule string
state *HostState
host *Host
}{
{"missing memory host", "memory", &HostState{MemUsed: 1}, nil},
{"zero memory total", "memory", &HostState{MemUsed: 1}, &Host{}},
{"missing GPU", "gpu_max", &HostState{}, &Host{}},
{"missing temperature", "temperature_max", &HostState{}, &Host{}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := &Server{Common: Common{ID: 1}, State: tc.state, Host: tc.host}
passed, known := (&Rule{Type: tc.rule, Max: 50, Cover: RuleCoverAll}).Evaluate(nil, server, nil)
if !passed || known {
t.Fatalf("got passed=%v known=%v, want unknown", passed, known)
}
})
}
}
func TestOfflineRuleDoesNotRequireMetricState(t *testing.T) {
server := &Server{Common: Common{ID: 1}}
InitServer(server)
passed, known := (&Rule{Type: "offline", Cover: RuleCoverAll}).Evaluate(nil, server, nil)
if passed || !known {
t.Fatalf("offline without state = (%v, %v), want known breach", passed, known)
}
}
func TestCompoundAlertUsesOneImmutableAgentReport(t *testing.T) {
server := &Server{Common: Common{ID: 1}}
alert := &AlertRule{Rules: []*Rule{
{Type: "cpu", Max: 80, Cover: RuleCoverAll},
{Type: "memory", Max: 80, Cover: RuleCoverAll},
}}
oldReport := RuntimeSnapshot{
State: &HostState{CPU: 99, MemUsed: 0},
Host: &Host{MemTotal: 100},
}
newReport := RuntimeSnapshot{
State: &HostState{CPU: 0, MemUsed: 99},
Host: &Host{MemTotal: 100},
}
for _, report := range []RuntimeSnapshot{oldReport, newReport} {
point, known := alert.SnapshotStatusWithRuntime(nil, server, report, nil)
if !known || len(point) != 2 || point[0] == point[1] {
t.Fatalf("compound point combined incompatible reports: known=%v point=%v", known, point)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package model
import (
"testing"
"time"
)
func timedRule(kind string, duration uint64) *AlertRule {
return &AlertRule{Rules: []*Rule{{Type: kind, Duration: duration, Max: 80, Cover: RuleCoverAll}}}
}
func timedPoints(start time.Time, values []bool, spacing time.Duration) []TimedAlertPoint {
points := make([]TimedAlertPoint, len(values))
for i, value := range values {
points[i] = TimedAlertPoint{At: start.Add(time.Duration(i) * spacing), Values: []bool{value}}
}
return points
}
func TestTimedAlertNeedsElapsedWindowAndDistinctReports(t *testing.T) {
start := time.Unix(1_700_000_000, 0)
rule := timedRule("cpu", 5)
points := timedPoints(start, []bool{false, false, false, false}, AlertSampleInterval)
if known, _ := rule.CheckTimed(points, points[len(points)-1].At); known {
t.Fatal("four reports decided a five-tick window")
}
points = append(points, TimedAlertPoint{At: start.Add(12 * time.Second), Values: []bool{false}})
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || passed {
t.Fatalf("complete high-CPU window: known=%v passed=%v", known, passed)
}
// Repeating the same observation does not advance wall-clock coverage.
if known, _ := rule.CheckTimed(points, start.Add(30*time.Second)); known {
t.Fatal("stale sample created a new complete window")
}
}
func TestTimedAlertGapIsUnknownAndCannotResolve(t *testing.T) {
start := time.Unix(1_700_000_000, 0)
rule := timedRule("cpu", 5)
points := []TimedAlertPoint{
{At: start, Values: []bool{false}},
{At: start.Add(30 * time.Second), Values: []bool{true}},
}
if known, _ := rule.CheckTimed(points, start.Add(30*time.Second)); known {
t.Fatal("telemetry gap must not be interpreted as healthy")
}
}
func TestTimedAlertIrregularReportsUseElapsedTime(t *testing.T) {
start := time.Unix(1_700_000_000, 0)
rule := timedRule("cpu", 6)
points := timedPoints(start, []bool{false, false, false, false}, 5*time.Second)
if known, passed := rule.CheckTimed(points, start.Add(15*time.Second)); !known || passed {
t.Fatalf("irregular reports cover the wall-clock window: known=%v passed=%v", known, passed)
}
}
func TestTimedAlertExactSeventyPercentIsNotIncident(t *testing.T) {
start := time.Unix(1_700_000_000, 0)
rule := timedRule("cpu", 10)
values := []bool{false, false, false, false, false, false, false, true, true, true}
points := timedPoints(start, values, AlertSampleInterval)
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || !passed {
t.Fatalf("70%% failed is below the >70%% trigger: known=%v passed=%v", known, passed)
}
}
func TestTimedAlertTenTickWindowDoesNotDecideAtNine(t *testing.T) {
start := time.Unix(1_700_000_000, 0)
rule := timedRule("cpu", 10)
points := timedPoints(start, []bool{false, false, false, false, false, false, false, false, false}, AlertSampleInterval)
if known, _ := rule.CheckTimed(points, points[len(points)-1].At); known {
t.Fatal("nine reports decided a ten-tick window one tick early")
}
points = append(points, TimedAlertPoint{At: start.Add(27 * time.Second), Values: []bool{false}})
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || passed {
t.Fatalf("full ten-tick breach: known=%v passed=%v", known, passed)
}
}
func TestTimedOfflineRequiresSustainedAbsence(t *testing.T) {
start := time.Unix(1_700_000_000, 0)
rule := timedRule("offline", 5)
points := timedPoints(start, []bool{false, false, false, false, false}, AlertSampleInterval)
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || passed {
t.Fatalf("continuous offline interval: known=%v passed=%v", known, passed)
}
points = append(points, TimedAlertPoint{At: start.Add(15 * time.Second), Values: []bool{true}})
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || !passed {
t.Fatalf("online observation should resolve offline: known=%v passed=%v", known, passed)
}
}
func TestTimedCompoundRuleWaitsForAllKnown(t *testing.T) {
start := time.Unix(1_700_000_000, 0)
rule := &AlertRule{Rules: []*Rule{
{Type: "cpu", Duration: 5, Max: 80, Cover: RuleCoverAll},
{Type: "memory", Duration: 5, Max: 80, Cover: RuleCoverAll},
}}
points := timedPoints(start, []bool{false, false, false, false, false}, AlertSampleInterval)
if known, _ := rule.CheckTimed(points, points[len(points)-1].At); known {
t.Fatal("missing second condition treated as decisive")
}
}
+213 -15
View File
@@ -2,12 +2,134 @@ package model
import (
"slices"
"time"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"gorm.io/gorm"
)
const (
AlertSampleInterval = 3 * time.Second
AlertSampleMaxAge = 6 * time.Second
)
// TimedAlertPoint records one distinct observation. Its timestamp is the
// dashboard receipt time of the Agent report, or the sentinel tick for an
// offline-only rule.
type TimedAlertPoint struct {
At time.Time
Values []bool
}
// CheckTimed evaluates the legacy Duration as Duration * 3 seconds, avoiding
// both repeated stale samples and cadence-dependent early incidents. Unknown
// coverage cannot create an incident or a recovery.
func (r *AlertRule) CheckTimed(points []TimedAlertPoint, now time.Time) (known, passed bool) {
if r == nil || len(r.Rules) == 0 {
return false, false
}
allFailed := true
for index, rule := range r.Rules {
if rule == nil || !rule.IsSupportedType() {
return false, false
}
ruleKnown, rulePassed := rule.checkTimed(points, index, now)
if !ruleKnown {
return false, false
}
if rulePassed {
allFailed = false
}
}
return true, !allFailed
}
func (rule *Rule) checkTimed(points []TimedAlertPoint, index int, now time.Time) (known, passed bool) {
if len(points) == 0 {
return false, false
}
if rule.IsTransferDurationRule() {
last := points[len(points)-1]
if index >= len(last.Values) || now.Sub(last.At) > AlertSampleMaxAge {
return false, false
}
return true, last.Values[index]
}
window := time.Duration(rule.Duration) * AlertSampleInterval
if window <= 0 {
return false, false
}
end := now.Add(AlertSampleInterval) // the current observation represents one tick
start := end.Add(-window)
if points[0].At.After(start) {
return false, false // the configured wall-clock window has not elapsed
}
var covered, failed time.Duration
for i, point := range points {
if index >= len(point.Values) {
return false, false
}
segmentStart := point.At
if start.After(segmentStart) {
segmentStart = start
}
segmentEnd := point.At.Add(AlertSampleMaxAge)
if end.Before(segmentEnd) {
segmentEnd = end
}
if i+1 < len(points) {
if points[i+1].At.Before(segmentEnd) {
segmentEnd = points[i+1].At
}
} else {
if tickEnd := point.At.Add(AlertSampleInterval); tickEnd.Before(segmentEnd) {
segmentEnd = tickEnd
}
}
if !segmentEnd.After(segmentStart) {
continue
}
span := segmentEnd.Sub(segmentStart)
covered += span
if !point.Values[index] {
failed += span
}
}
if covered < window-window/10 {
return false, false
}
if rule.IsOfflineRule() {
if covered-failed >= AlertSampleInterval {
return true, true
}
if failed >= window-AlertSampleInterval && covered >= window-AlertSampleInterval {
return true, false
}
return false, false
}
if float64(failed) > float64(window)*0.70 {
return true, false
}
if float64(failed+window-covered) <= float64(window)*0.70 {
return true, true
}
return false, false
}
func (r *AlertRule) TimedRetention() time.Duration {
longest := AlertSampleMaxAge
for _, rule := range r.Rules {
if rule == nil || rule.IsTransferDurationRule() {
continue
}
if duration, ok := rule.DurationInt(); ok {
longest = max(longest, time.Duration(duration)*AlertSampleInterval+AlertSampleMaxAge)
}
}
return longest
}
const (
ModeAlwaysTrigger = 0
ModeOnetimeTrigger = 1
@@ -64,6 +186,39 @@ func (r *AlertRule) Enabled() bool {
return r.Enable != nil && *r.Enable
}
// IsSafeToEvaluate validates persisted rule data before it reaches the alert
// goroutine. API validation protects new writes; this second boundary protects
// upgrades from malformed or deliberately poisoned rows already in the DB.
func (r *AlertRule) IsSafeToEvaluate() bool {
if r == nil || len(r.Rules) == 0 {
return false
}
for _, rule := range r.Rules {
if rule == nil || !rule.IsSupportedType() {
return false
}
if !rule.HasSafeThresholds() {
return false
}
switch rule.Cover {
case RuleCoverAll, RuleCoverIgnoreAll:
default:
return false
}
if rule.IsTransferDurationRule() {
if !rule.HasSafeCycleConfiguration() {
return false
}
continue
}
duration, ok := rule.DurationInt()
if !ok || duration < 3 {
return false
}
}
return true
}
// HasPermission extends the default owner/admin check with PAT
// server_ids whitelist enforcement. AlertRule.Snapshot fans out across
// every owner-visible server filtered only by Rule.Ignore semantics
@@ -122,22 +277,52 @@ func (r *AlertRule) HasPermission(ctx *gin.Context) bool {
// Snapshot 对传入的Server进行该报警规则下所有type的检查 返回每项检查结果
func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []bool {
point, _ := r.SnapshotStatus(cycleTransferStats, server, db)
return point
}
// SnapshotStatus preserves the distinction between a healthy measurement and
// telemetry that is unavailable. A compound alert cannot be decided while any
// of its applicable conditions is unknown.
func (r *AlertRule) SnapshotStatus(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) ([]bool, bool) {
if server == nil {
return nil, false
}
return r.SnapshotStatusWithRuntime(cycleTransferStats, server, server.RuntimeSnapshot(), db)
}
// SnapshotStatusWithRuntime uses one immutable report for every condition.
func (r *AlertRule) SnapshotStatusWithRuntime(cycleTransferStats *CycleTransferStats, server *Server, runtime RuntimeSnapshot, db *gorm.DB) ([]bool, bool) {
point := make([]bool, len(r.Rules))
known := true
for i, rule := range r.Rules {
point[i] = rule.Snapshot(cycleTransferStats, server, db)
if rule == nil || !rule.IsSupportedType() {
// Invalid persisted rules are ignored instead of being interpreted as
// a failed condition or allowed to panic the sentinel.
point[i] = true
known = false
continue
}
var valid bool
point[i], valid = rule.EvaluateRuntime(cycleTransferStats, server, runtime, db)
known = known && valid
}
return point
return point, known
}
// Check 传入包含当前报警规则下所有type检查结果 返回报警持续时间与是否通过报警检查(通过则返回true)
func (r *AlertRule) Check(points [][]bool) (int, bool) {
var hasPassedRule bool
durations := make([]int, len(r.Rules))
validRules := 0
for ruleIndex, rule := range r.Rules {
duration := int(rule.Duration)
if rule == nil || !rule.IsSupportedType() {
continue
}
if rule.IsTransferDurationRule() {
validRules++
// 循环区间流量报警
if durations[ruleIndex] < 1 {
durations[ruleIndex] = 1
@@ -146,10 +331,21 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
continue
}
// 只要最后一次检查超出了规则范围 就认为检查未通过
if len(points) > 0 && points[len(points)-1][ruleIndex] {
hasPassedRule = true
if len(points) > 0 {
lastPoint := points[len(points)-1]
if ruleIndex >= len(lastPoint) || lastPoint[ruleIndex] {
hasPassedRule = true
}
}
} else if rule.IsOfflineRule() {
continue
}
duration, ok := rule.DurationInt()
if !ok || duration <= 0 {
continue
}
validRules++
if rule.IsOfflineRule() {
// 离线报警,检查直到最后一次在线的离线采样点是否大于 duration
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
continue
@@ -157,7 +353,7 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
var fail int
for _, point := range slices.Backward(points[len(points)-duration:]) {
fail++
if point[ruleIndex] {
if ruleIndex >= len(point) || point[ruleIndex] {
hasPassedRule = true
break
}
@@ -168,11 +364,7 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
// 常规报警
// duration<=0 是无意义的规则(持续 0 秒):直接跳过该规则,
// 既不污染 hasPassedRule(否则会连带跳过同一 alert 里其它有效
// 规则),也避免下方 fail*100/total 在 total=0 时整数除零 panic
// —— checkStatus 无 recover,一次 panic 会拖垮整个告警 goroutine。
if duration <= 0 {
continue
}
// 规则),也避免下方百分比计算在 total=0 时除零。
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
continue
}
@@ -181,17 +373,20 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
}
total, fail := duration, 0
for timeTick := len(points) - duration; timeTick < len(points); timeTick++ {
if !points[timeTick][ruleIndex] {
if ruleIndex >= len(points[timeTick]) || !points[timeTick][ruleIndex] {
fail++
}
}
// 当70%以上的采样点未通过规则判断时 才认为当前检查未通过
if fail*100/total <= 70 {
if float64(fail)*100/float64(total) <= 70 {
hasPassedRule = true
}
}
}
if validRules == 0 {
return 0, true
}
// 仅当所有检查均未通过时 才触发告警
return slices.Max(durations), hasPassedRule
}
@@ -205,10 +400,13 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
func (r *AlertRule) RetentionWindow() int {
window := 0
for _, rule := range r.Rules {
if rule == nil || !rule.IsSupportedType() {
continue
}
var need int
if rule.IsTransferDurationRule() {
need = 1
} else if d := int(rule.Duration); d > 0 {
} else if d, ok := rule.DurationInt(); ok && d > 0 {
need = d
}
if need > window {
+123
View File
@@ -0,0 +1,123 @@
package model
import (
"math"
"testing"
"time"
)
func TestRuleSnapshotIgnoresEmptyMetricSamples(t *testing.T) {
tests := []struct {
name string
ruleType string
hostState *HostState
}{
{
name: "GPU list is empty",
ruleType: "gpu_max",
hostState: &HostState{},
},
{
name: "all temperature samples are filtered",
ruleType: "temperature_max",
hostState: &HostState{Temperatures: []SensorTemperature{
{Name: "coretemp", Temperature: 0},
}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := &Server{Common: Common{ID: 1}, State: test.hostState, Host: &Host{}}
rule := &Rule{Type: test.ruleType, Max: 1, Duration: 3, Cover: RuleCoverAll}
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("Snapshot panicked for missing metric data: %v", recovered)
}
}()
if passed := rule.Snapshot(nil, server, nil); !passed {
t.Fatal("missing metric data must be ignored instead of treated as a failed threshold")
}
})
}
}
func TestAlertRuleCheckRejectsOverflowingPersistedDuration(t *testing.T) {
rule := &AlertRule{Rules: []*Rule{{
Type: "offline",
Duration: math.MaxUint64,
Cover: RuleCoverAll,
}}}
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("Check panicked after uint64-to-int overflow: %v", recovered)
}
}()
duration, passed := rule.Check([][]bool{{false}})
if duration != 0 || !passed {
t.Fatalf("invalid persisted duration must be ignored safely, got duration=%d passed=%v", duration, passed)
}
if window := rule.RetentionWindow(); window != 0 {
t.Fatalf("invalid persisted duration must not create a retention window, got %d", window)
}
}
func TestAlertRulePersistedDataSafety(t *testing.T) {
cycleStart := time.Now().Add(-time.Hour)
tests := []struct {
name string
rule *AlertRule
want bool
}{
{
name: "normal rule",
rule: &AlertRule{Rules: []*Rule{{
Type: "cpu", Duration: 3, Cover: RuleCoverAll,
}}},
want: true,
},
{
name: "normal cycle rule",
rule: &AlertRule{Rules: []*Rule{{
Type: "transfer_in_cycle", CycleStart: &cycleStart, CycleInterval: 1, Cover: RuleCoverAll,
}}},
want: true,
},
{
name: "unknown type",
rule: &AlertRule{Rules: []*Rule{{
Type: "attacker_controlled", Duration: 3, Cover: RuleCoverAll,
}}},
},
{
name: "overflowing duration",
rule: &AlertRule{Rules: []*Rule{{
Type: "offline", Duration: math.MaxUint64, Cover: RuleCoverAll,
}}},
},
{
name: "cycle without start",
rule: &AlertRule{Rules: []*Rule{{
Type: "transfer_in_cycle", CycleInterval: 1, Cover: RuleCoverAll,
}}},
},
{
name: "nil rule",
rule: &AlertRule{Rules: []*Rule{nil}},
},
{
name: "empty rule list",
rule: &AlertRule{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.rule.IsSafeToEvaluate(); got != test.want {
t.Fatalf("IsSafeToEvaluate()=%v, want %v", got, test.want)
}
})
}
}
+16 -4
View File
@@ -26,7 +26,7 @@ func testCycleRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "_cycle",
Type: "transfer_in_cycle",
},
},
},
@@ -39,7 +39,7 @@ func testCycleRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "_cycle",
Type: "transfer_in_cycle",
},
},
},
@@ -130,6 +130,7 @@ func testGeneralRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "cpu",
Duration: 10,
},
},
@@ -143,6 +144,7 @@ func testGeneralRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "cpu",
Duration: 10,
},
},
@@ -156,6 +158,7 @@ func testGeneralRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "cpu",
Duration: 10,
},
},
@@ -169,6 +172,7 @@ func testGeneralRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "cpu",
Duration: 10,
},
},
@@ -182,6 +186,7 @@ func testGeneralRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "cpu",
Duration: 10,
},
},
@@ -210,6 +215,7 @@ func testCombinedRules(t *testing.T) {
Duration: 10,
},
{
Type: "cpu",
Duration: 10,
},
},
@@ -227,6 +233,7 @@ func testCombinedRules(t *testing.T) {
Duration: 10,
},
{
Type: "cpu",
Duration: 10,
},
},
@@ -240,6 +247,7 @@ func testCombinedRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "cpu",
Duration: 10,
},
{
@@ -257,9 +265,11 @@ func testCombinedRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "cpu",
Duration: 10,
},
{
Type: "memory",
Duration: 30,
},
},
@@ -273,9 +283,11 @@ func testCombinedRules(t *testing.T) {
rule: &AlertRule{
Rules: []*Rule{
{
Type: "cpu",
Duration: 10,
},
{
Type: "memory",
Duration: 30,
},
},
@@ -416,7 +428,7 @@ func TestAlertRule_RetentionWindow(t *testing.T) {
{"zero duration only", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}}}, 0},
{"mixed picks max", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}, {Type: "cpu", Duration: 7}}}, 7},
{"offline keeps Duration", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 30}}}, 30},
{"cycle looks back one", &AlertRule{Rules: []*Rule{{Type: "net_in_speed_cycle"}}}, 1},
{"cycle looks back one", &AlertRule{Rules: []*Rule{{Type: "transfer_in_cycle"}}}, 1},
}
for _, c := range cases {
if got := c.rule.RetentionWindow(); got != c.want {
@@ -469,7 +481,7 @@ func TestAlertRule_CombinedRuleAccumulatesSamples(t *testing.T) {
}{
{"general3+general10", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 3}, {Type: "memory", Duration: 10}}}, []bool{false, false}, 9, 10},
{"offline5+general10", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 5}, {Type: "cpu", Duration: 10}}}, []bool{false, false}, 9, 10},
{"transfer+general8", &AlertRule{Rules: []*Rule{{Type: "net_in_speed_cycle"}, {Type: "cpu", Duration: 8}}}, []bool{false, false}, 7, 8},
{"transfer+general8", &AlertRule{Rules: []*Rule{{Type: "transfer_in_cycle"}, {Type: "cpu", Duration: 8}}}, []bool{false, false}, 7, 8},
{"offline3+offline12", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 3}, {Type: "offline", Duration: 12}}}, []bool{false, false}, 11, 12},
}
for _, c := range cases {
+32
View File
@@ -66,6 +66,9 @@ type ConfigDashboard struct {
AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"`
EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
// AllowJWTIPChange disables the IP binding check for browser login sessions.
// Keep this disabled by default so a stolen JWT remains bound to its issuing IP.
AllowJWTIPChange bool `koanf:"allow_jwt_ip_change" json:"allow_jwt_ip_change,omitempty"`
EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist)
@@ -91,6 +94,10 @@ type ConfigDashboard struct {
AdminEmail string `koanf:"admin_email" json:"admin_email,omitempty"`
DomainExpiryNotificationDays string `koanf:"domain_expiry_notification_days" json:"domain_expiry_notification_days,omitempty"`
ServerExpiryNotificationDays string `koanf:"server_expiry_notification_days" json:"server_expiry_notification_days,omitempty"`
// OnlineStatsDays 设备在线时间段统计的保留与查询窗口(天)。
// 取值范围 [MinOnlineStatsDays, MaxOnlineStatsDays],默认 DefaultOnlineStatsDays。
OnlineStatsDays int `koanf:"online_stats_days" json:"online_stats_days,omitempty"`
}
type Config struct {
@@ -118,6 +125,10 @@ type Config struct {
// SettingResponse 按值拷贝 ConfigDashboard 触发 copylocks。
mcpEnabled atomic.Bool `koanf:"-" json:"-" yaml:"-"`
// jwtIPChangeAllowed mirrors AllowJWTIPChange for concurrent authentication
// and setting updates. Keep atomic fields outside ConfigDashboard.
jwtIPChangeAllowed atomic.Bool `koanf:"-" json:"-" yaml:"-"`
// oauth2 配置
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
@@ -240,6 +251,15 @@ 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 == "" {
c.AgentSecretKey, err = utils.GenerateRandomString(32)
if err != nil {
@@ -267,6 +287,17 @@ func (c *Config) SetMCPEnabled(v bool) {
c.mcpEnabled.Store(v)
}
// JWTIPChangeAllowed reports whether JWT sessions may change IP concurrently.
func (c *Config) JWTIPChangeAllowed() bool {
return c.jwtIPChangeAllowed.Load()
}
// SetJWTIPChangeAllowed updates only the atomic mirror. save() synchronizes
// the public field for persistence; authentication must use the accessor.
func (c *Config) SetJWTIPChangeAllowed(v bool) {
c.jwtIPChangeAllowed.Store(v)
}
// Save 保存配置文件
func (c *Config) Save() error {
return c.save()
@@ -335,6 +366,7 @@ func (c *Config) patchYAMLField(key string, value any) error {
func (c *Config) save() error {
c.EnableMCP = c.mcpEnabled.Load()
c.AllowJWTIPChange = c.jwtIPChangeAllowed.Load()
data, err := yaml.Marshal(c)
if err != nil {
return err
+3
View File
@@ -24,6 +24,7 @@ func TestReadConfig(t *testing.T) {
{"user_template", c.UserTemplate, c.UserTemplate == "user-dist"},
{"admin_template", c.AdminTemplate, c.AdminTemplate == "admin-dist"},
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey != ""},
{"allow_jwt_ip_change", c.AllowJWTIPChange, !c.AllowJWTIPChange},
}
for _, field := range testFields {
@@ -37,6 +38,7 @@ func TestReadConfig(t *testing.T) {
t.Run("ReadFile", func(t *testing.T) {
const testCfg = `
allow_jwt_ip_change: true
jwt_secret_key: test
user_template: um
admin_template: am
@@ -85,6 +87,7 @@ admin_email: admin@loohui.com
{"smtp_user", c.SMTPUser, c.SMTPUser == "test@loohui.com"},
{"smtp_password", c.SMTPPassword, c.SMTPPassword == "password123"},
{"admin_email", c.AdminEmail, c.AdminEmail == "admin@loohui.com"},
{"allow_jwt_ip_change", c.AllowJWTIPChange, c.AllowJWTIPChange},
}
for _, field := range testFields {
+30
View File
@@ -35,9 +35,28 @@ type HostState struct {
ProcessCount uint64 `json:"process_count,omitempty"`
Temperatures []SensorTemperature `json:"temperatures,omitempty"`
GPU []float64 `json:"gpu,omitempty"`
GPUs []GPUStat `json:"gpus,omitempty"`
}
// GPUStat carries per-card figures, index-aligned with Host.GPU. Memory is in
// MiB and stays zero for vendors that do not report it, so MemoryTotal == 0
// means "unknown" rather than "no memory".
type GPUStat struct {
Utilization float64 `json:"utilization"`
MemoryUsed uint64 `json:"memory_used,omitempty"`
MemoryTotal uint64 `json:"memory_total,omitempty"`
}
func (s *HostState) PB() *pb.State {
gs := make([]*pb.State_GPU, 0, len(s.GPUs))
for _, g := range s.GPUs {
gs = append(gs, &pb.State_GPU{
Utilization: g.Utilization,
MemoryUsed: g.MemoryUsed,
MemoryTotal: g.MemoryTotal,
})
}
var ts []*pb.State_SensorTemperature
for _, t := range s.Temperatures {
ts = append(ts, &pb.State_SensorTemperature{
@@ -64,10 +83,20 @@ func (s *HostState) PB() *pb.State {
ProcessCount: s.ProcessCount,
Temperatures: ts,
Gpu: s.GPU,
Gpus: gs,
}
}
func PB2State(s *pb.State) HostState {
var gs []GPUStat
for _, g := range s.GetGpus() {
gs = append(gs, GPUStat{
Utilization: g.GetUtilization(),
MemoryUsed: g.GetMemoryUsed(),
MemoryTotal: g.GetMemoryTotal(),
})
}
var ts []SensorTemperature
for _, t := range s.GetTemperatures() {
ts = append(ts, SensorTemperature{
@@ -93,6 +122,7 @@ func PB2State(s *pb.State) HostState {
UdpConnCount: s.GetUdpConnCount(),
ProcessCount: s.GetProcessCount(),
Temperatures: ts,
GPUs: gs,
GPU: s.GetGpu(),
}
}
+56
View File
@@ -0,0 +1,56 @@
package model
import (
"path/filepath"
"sync"
"testing"
)
func TestJWTIPChangeAllowedPersistence(t *testing.T) {
c := &Config{}
if c.JWTIPChangeAllowed() {
t.Fatal("zero-value Config must bind JWT sessions to their IP")
}
// An absent agent secret makes Read save the config during initialization.
path := newTempConfig(t, "jwt_secret_key: test\nallow_jwt_ip_change: true\n")
if err := c.Read(path, nil); err != nil {
t.Fatal(err)
}
if !c.JWTIPChangeAllowed() {
t.Fatal("Read must initialize the atomic mirror before saving")
}
for _, want := range []bool{true, false} {
c.SetJWTIPChangeAllowed(want)
if err := c.Save(); err != nil {
t.Fatal(err)
}
reloaded := &Config{}
if err := reloaded.Read(path, nil); err != nil {
t.Fatal(err)
}
if c.AllowJWTIPChange != want || reloaded.JWTIPChangeAllowed() != want {
t.Fatalf("saved and reloaded setting must be %v", want)
}
}
}
func TestJWTIPChangeAllowedConcurrentAccess(t *testing.T) {
c := &Config{filePath: filepath.Join(t.TempDir(), "config.yaml")}
var wg sync.WaitGroup
wg.Go(func() {
for i := 0; i < 10000; i++ {
c.SetJWTIPChangeAllowed(i%2 == 0)
}
})
wg.Go(func() {
for i := 0; i < 10000; i++ {
_ = c.JWTIPChangeAllowed()
}
})
for i := 0; i < 20; i++ {
if err := c.Save(); err != nil {
t.Error(err)
}
}
wg.Wait()
}
+7 -8
View File
@@ -208,6 +208,7 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
}
if ns.Server != nil {
runtime := ns.Server.RuntimeSnapshot()
var noteData struct {
BillingDataMod struct {
EndDate string `json:"endDate"`
@@ -238,8 +239,6 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
"#SERVER.BILLING_AMOUNT#", mod(amountStr),
"#SERVER.BILLING_CYCLE#", mod(cycleStr),
)
runtime := ns.Server.RuntimeSnapshot()
if runtime.State != nil && runtime.Host != nil {
state := runtime.State
host := runtime.Host
@@ -321,13 +320,13 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
ipv6 = ip.IPv6Addr
validIP = ipv6
}
}
replacements = append(replacements,
"#SERVER.IP#", mod(validIP),
"#SERVER.IPV4#", mod(ipv4),
"#SERVER.IPV6#", mod(ipv6),
)
replacements = append(replacements,
"#SERVER.IP#", mod(validIP),
"#SERVER.IPV4#", mod(ipv4),
"#SERVER.IPV6#", mod(ipv6),
)
}
}
replacer := strings.NewReplacer(replacements...)
+211
View File
@@ -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()
}
+220
View File
@@ -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)))
}
}
+223 -114
View File
@@ -1,6 +1,8 @@
package model
import (
"log"
"math"
"slices"
"strings"
"time"
@@ -15,12 +17,23 @@ const (
RuleCoverIgnoreAll
)
// MaxAlertRuleDuration is the largest duration that can be converted to int
// on every architecture supported by Go. Alert rules are persisted as uint64,
// but their sampling windows use int indexes; keeping the public bound at
// MaxInt32 prevents a crafted value from wrapping during that conversion.
const MaxAlertRuleDuration uint64 = 1<<31 - 1
// MaxAlertRuleCycleInterval also leaves room for the largest integer
// multiplication performed by the calendar helpers (weeks become 7*interval)
// on 32-bit targets.
const MaxAlertRuleCycleInterval uint64 = (1<<31 - 1) / 7
type NResult struct {
N uint64
}
type Rule struct {
// 指标类型,cpu、memory、swap、disk、net_in_speed、net_out_speed
// 指标类型,cpu、gpu/gpu_max、memory、swap、disk、net_in_speed、net_out_speed
// net_all_speed、transfer_in、transfer_out、transfer_all、offline
// transfer_in_cycle、transfer_out_cycle、transfer_all_cycle
Type string `json:"type"`
@@ -45,47 +58,147 @@ func percentage(used, total uint64) float64 {
return float64(used) * 100 / float64(total)
}
// Snapshot 未通过规则返回 false, 通过返回 true
// IsSupportedType reports whether the rule type has an evaluator. Keep this
// list in lockstep with Snapshot's switch so untrusted strings cannot enter the
// persisted alert pipeline and silently acquire fallback semantics.
func (u *Rule) IsSupportedType() bool {
if u == nil {
return false
}
switch u.Type {
case "cpu", "gpu", "gpu_max", "memory", "swap", "disk",
"net_in_speed", "net_out_speed", "net_all_speed",
"transfer_in", "transfer_out", "transfer_all", "offline",
"transfer_in_cycle", "transfer_out_cycle", "transfer_all_cycle",
"load1", "load5", "load15", "tcp_conn_count", "udp_conn_count",
"process_count", "temperature_max":
return true
default:
return false
}
}
// DurationInt converts the persisted duration without allowing uint64-to-int
// truncation. Callers must handle ok=false as an invalid rule.
func (u *Rule) DurationInt() (duration int, ok bool) {
if u == nil || u.Duration > MaxAlertRuleDuration {
return 0, false
}
return int(u.Duration), true
}
// HasSafeCycleConfiguration guards every value that the cycle evaluator later
// converts or dereferences. It intentionally does not reject an empty unit,
// which is the documented legacy spelling for hours.
func (u *Rule) HasSafeCycleConfiguration() bool {
return u != nil && u.IsTransferDurationRule() && u.CycleStart != nil &&
!u.CycleStart.After(time.Now()) && u.CycleInterval > 0 && u.CycleInterval <= MaxAlertRuleCycleInterval &&
u.HasSafeThresholds() && u.HasSafeCycleUnit()
}
func (u *Rule) HasSafeCycleUnit() bool {
if u == nil {
return false
}
switch strings.ToLower(u.CycleUnit) {
case "", "hour", "day", "week", "month", "year":
return true
default:
return false
}
}
func (u *Rule) HasSafeThresholds() bool {
if u == nil || math.IsNaN(u.Min) || math.IsNaN(u.Max) || math.IsInf(u.Min, 0) || math.IsInf(u.Max, 0) || u.Min < 0 || u.Max < 0 {
return false
}
return u.Min == 0 || u.Max == 0 || u.Min <= u.Max
}
// Snapshot is retained for callers which only need a boolean result. The alert
// sentinel uses Evaluate so missing telemetry is not mistaken for a breach.
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) bool {
passed, _ := u.Evaluate(cycleTransferStats, server, db)
return passed
}
// Evaluate returns known=false when a metric cannot be evaluated from the
// current report. Unknown must neither trigger nor resolve an incident.
func (u *Rule) Evaluate(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) (passed, known bool) {
if server == nil {
return true, false
}
return u.EvaluateRuntime(cycleTransferStats, server, server.RuntimeSnapshot(), db)
}
// EvaluateRuntime evaluates all conditions against the same immutable Agent
// report. Callers evaluating a compound rule must share this snapshot.
func (u *Rule) EvaluateRuntime(cycleTransferStats *CycleTransferStats, server *Server, runtime RuntimeSnapshot, db *gorm.DB) (passed, known bool) {
if u == nil || server == nil || !u.IsSupportedType() {
return true, false
}
if u.IsTransferDurationRule() && (!u.HasSafeCycleConfiguration() || cycleTransferStats == nil) {
return true, false
}
// 监控全部但是排除了此服务器
if u.Cover == RuleCoverAll && u.Ignore[server.ID] {
return true
return true, true
}
// 忽略全部但是指定监控了此服务器
if u.Cover == RuleCoverIgnoreAll && !u.Ignore[server.ID] {
return true
return true, true
}
evalNow := time.Now()
var cycleStart, cycleEnd time.Time
if u.IsTransferDurationRule() {
cycleStart, cycleEnd = u.transferDurationBounds(evalNow)
}
// 循环区间流量检测 · 短期无需重复检测
if u.IsTransferDurationRule() && u.NextTransferAt[server.ID].After(time.Now()) {
return u.LastCycleStatus[server.ID]
if u.IsTransferDurationRule() && u.NextTransferAt[server.ID].After(evalNow) {
return u.LastCycleStatus[server.ID], true
}
var src float64
runtime := server.RuntimeSnapshot()
if u.IsOfflineRule() {
return !runtime.LastActive.IsZero() && time.Since(runtime.LastActive) <= 6*time.Second, true
}
if runtime.State == nil {
return false
return true, false
}
state := runtime.State
switch u.Type {
case "cpu":
src = float64(state.CPU)
case "gpu_max":
case "gpu", "gpu_max":
if len(state.GPU) == 0 {
return true, false
}
src = slices.Max(state.GPU)
case "memory":
if runtime.Host == nil {
return false
return true, false
}
if runtime.Host.MemTotal == 0 {
return true, false
}
src = percentage(state.MemUsed, runtime.Host.MemTotal)
case "swap":
if runtime.Host == nil {
return false
return true, false
}
if runtime.Host.SwapTotal == 0 {
return true, false
}
src = percentage(state.SwapUsed, runtime.Host.SwapTotal)
case "disk":
if runtime.Host == nil {
return false
return true, false
}
if runtime.Host.DiskTotal == 0 {
return true, false
}
src = percentage(state.DiskUsed, runtime.Host.DiskTotal)
case "net_in_speed":
@@ -93,38 +206,50 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
case "net_out_speed":
src = float64(state.NetOutSpeed)
case "net_all_speed":
src = float64(state.NetOutSpeed + state.NetOutSpeed)
src = float64(state.NetInSpeed) + float64(state.NetOutSpeed)
case "transfer_in":
src = float64(state.NetInTransfer)
case "transfer_out":
src = float64(state.NetOutTransfer)
case "transfer_all":
src = float64(state.NetOutTransfer + state.NetInTransfer)
case "offline":
if runtime.LastActive.IsZero() {
src = 0
} else {
src = float64(runtime.LastActive.Unix())
}
case "transfer_in_cycle":
if db == nil {
return true, false
}
src = float64(utils.SubUintChecked(state.NetInTransfer, runtime.PrevTransferInSnapshot))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
if err := db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("datetime(`created_at`) >= datetime(?) AND datetime(`created_at`) < datetime(?) AND datetime(`created_at`) <= datetime(?) AND server_id = ?", cycleStart.UTC(), cycleEnd.UTC(), evalNow.UTC(), server.ID).Scan(&res).Error; err != nil {
log.Printf("NEZHA>> Alert cycle transfer query failed for rule %s server %d: %v", u.Type, server.ID, err)
return true, false
}
src += float64(res.N)
}
case "transfer_out_cycle":
if db == nil {
return true, false
}
src = float64(utils.SubUintChecked(state.NetOutTransfer, runtime.PrevTransferOutSnapshot))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
if err := db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND datetime(`created_at`) < datetime(?) AND datetime(`created_at`) <= datetime(?) AND server_id = ?", cycleStart.UTC(), cycleEnd.UTC(), evalNow.UTC(), server.ID).Scan(&res).Error; err != nil {
log.Printf("NEZHA>> Alert cycle transfer query failed for rule %s server %d: %v", u.Type, server.ID, err)
return true, false
}
src += float64(res.N)
}
case "transfer_all_cycle":
if db == nil {
return true, false
}
src = float64(utils.SubUintChecked(state.NetOutTransfer, runtime.PrevTransferOutSnapshot) + utils.SubUintChecked(state.NetInTransfer, runtime.PrevTransferInSnapshot))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
if err := db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND datetime(`created_at`) < datetime(?) AND datetime(`created_at`) <= datetime(?) AND server_id = ?", cycleStart.UTC(), cycleEnd.UTC(), evalNow.UTC(), server.ID).Scan(&res).Error; err != nil {
log.Printf("NEZHA>> Alert cycle transfer query failed for rule %s server %d: %v", u.Type, server.ID, err)
return true, false
}
src += float64(res.N)
}
case "load1":
@@ -141,26 +266,38 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
src = float64(state.ProcessCount)
case "temperature_max":
var temp []float64
if state.Temperatures != nil {
for _, tempStat := range state.Temperatures {
if tempStat.Temperature != 0 {
temp = append(temp, tempStat.Temperature)
}
for _, tempStat := range state.Temperatures {
if tempStat.Temperature != 0 {
temp = append(temp, tempStat.Temperature)
}
src = slices.Max(temp)
}
if len(temp) == 0 {
return true, false
}
src = slices.Max(temp)
default:
return true, false
}
// 循环区间流量检测 · 更新下次需要检测时间
if u.IsTransferDurationRule() {
seconds := max(1800*((u.Max-src)/u.Max), 180)
seconds := float64(180)
if u.Max > 0 {
seconds = max(1800*((u.Max-src)/u.Max), 180)
} else if u.Min > 0 {
seconds = 1800
}
if u.NextTransferAt == nil {
u.NextTransferAt = make(map[uint64]time.Time)
}
if u.LastCycleStatus == nil {
u.LastCycleStatus = make(map[uint64]bool)
}
u.NextTransferAt[server.ID] = time.Now().Add(time.Second * time.Duration(seconds))
nextAt := evalNow.Add(time.Second * time.Duration(seconds))
if nextAt.After(cycleEnd) {
nextAt = cycleEnd
}
u.NextTransferAt[server.ID] = nextAt
if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
u.LastCycleStatus[server.ID] = false
} else {
@@ -172,105 +309,77 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
cycleTransferStats.Transfer[server.ID] = uint64(src)
cycleTransferStats.NextUpdate[server.ID] = u.NextTransferAt[server.ID]
// 自动更新周期流量展示起止时间
cycleTransferStats.From = u.GetTransferDurationStart()
cycleTransferStats.To = u.GetTransferDurationEnd()
cycleTransferStats.From = cycleStart
cycleTransferStats.To = cycleEnd
}
if u.Type == "offline" && float64(time.Now().Unix())-src > 6 {
return false
} else if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
return false
if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
return false, true
}
return true
return true, true
}
// IsTransferDurationRule 判断该规则是否属于周期流量规则 属于则返回true
func (u *Rule) IsTransferDurationRule() bool {
return strings.HasSuffix(u.Type, "_cycle")
if u == nil {
return false
}
switch u.Type {
case "transfer_in_cycle", "transfer_out_cycle", "transfer_all_cycle":
return true
default:
return false
}
}
func (u *Rule) IsOfflineRule() bool {
return u.Type == "offline"
return u != nil && u.Type == "offline"
}
// transferDurationBounds computes both edges from the same clock reading.
// Separate time.Now calls can otherwise straddle a cycle boundary.
func (u *Rule) transferDurationBounds(now time.Time) (time.Time, time.Time) {
if !u.HasSafeCycleConfiguration() {
return time.Time{}, time.Time{}
}
start := *u.CycleStart
var end time.Time
switch strings.ToLower(u.CycleUnit) {
case "year":
end = start.AddDate(int(u.CycleInterval), 0, 0)
for !now.Before(end) {
start, end = end, end.AddDate(int(u.CycleInterval), 0, 0)
}
case "month":
end = start.AddDate(0, int(u.CycleInterval), 0)
for !now.Before(end) {
start, end = end, end.AddDate(0, int(u.CycleInterval), 0)
}
case "week":
end = start.AddDate(0, 0, 7*int(u.CycleInterval))
for !now.Before(end) {
start, end = end, end.AddDate(0, 0, 7*int(u.CycleInterval))
}
case "day":
end = start.AddDate(0, 0, int(u.CycleInterval))
for !now.Before(end) {
start, end = end, end.AddDate(0, 0, int(u.CycleInterval))
}
default: // empty is the legacy spelling for hour
interval := 3600 * int64(u.CycleInterval)
start = time.Unix(u.CycleStart.Unix()+(now.Unix()-u.CycleStart.Unix())/interval*interval, 0)
end = time.Unix(start.Unix()+interval, 0)
}
return start, end
}
// GetTransferDurationStart 获取周期流量的起始时间
func (u *Rule) GetTransferDurationStart() time.Time {
// Accept uppercase and lowercase
unit := strings.ToLower(u.CycleUnit)
startTime := *u.CycleStart
var nextTime time.Time
switch unit {
case "year":
nextTime = startTime.AddDate(int(u.CycleInterval), 0, 0)
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(int(u.CycleInterval), 0, 0)
}
case "month":
nextTime = startTime.AddDate(0, int(u.CycleInterval), 0)
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, int(u.CycleInterval), 0)
}
case "week":
nextTime = startTime.AddDate(0, 0, 7*int(u.CycleInterval))
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, 0, 7*int(u.CycleInterval))
}
case "day":
nextTime = startTime.AddDate(0, 0, int(u.CycleInterval))
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, 0, int(u.CycleInterval))
}
default:
// For hour unit or not set.
interval := 3600 * int64(u.CycleInterval)
startTime = time.Unix(u.CycleStart.Unix()+(time.Now().Unix()-u.CycleStart.Unix())/interval*interval, 0)
}
return startTime
start, _ := u.transferDurationBounds(time.Now())
return start
}
// GetTransferDurationEnd 获取周期流量结束时间
func (u *Rule) GetTransferDurationEnd() time.Time {
// Accept uppercase and lowercase
unit := strings.ToLower(u.CycleUnit)
startTime := *u.CycleStart
var nextTime time.Time
switch unit {
case "year":
nextTime = startTime.AddDate(int(u.CycleInterval), 0, 0)
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(int(u.CycleInterval), 0, 0)
}
case "month":
nextTime = startTime.AddDate(0, int(u.CycleInterval), 0)
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, int(u.CycleInterval), 0)
}
case "week":
nextTime = startTime.AddDate(0, 0, 7*int(u.CycleInterval))
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, 0, 7*int(u.CycleInterval))
}
case "day":
nextTime = startTime.AddDate(0, 0, int(u.CycleInterval))
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, 0, int(u.CycleInterval))
}
default:
// For hour unit or not set.
interval := 3600 * int64(u.CycleInterval)
startTime = time.Unix(u.CycleStart.Unix()+(time.Now().Unix()-u.CycleStart.Unix())/interval*interval, 0)
nextTime = time.Unix(startTime.Unix()+interval, 0)
}
return nextTime
_, end := u.transferDurationBounds(time.Now())
return end
}
+26 -13
View File
@@ -16,6 +16,7 @@ import (
)
var runtimeHolderInitMu sync.Mutex
var nextServerReportSequence atomic.Uint64
type Server struct {
Common
@@ -75,15 +76,16 @@ type taskStreamHolder struct {
}
type serverRuntimeHolder struct {
mu sync.Mutex
canonical *Server
stream pb.NezhaService_ReportSystemStateServer
generation uint64
state *HostState
host *Host
lastActive time.Time
prevIn uint64
prevOut uint64
mu sync.Mutex
canonical *Server
stream pb.NezhaService_ReportSystemStateServer
generation uint64
state *HostState
host *Host
lastActive time.Time
reportSequence uint64
prevIn uint64
prevOut uint64
}
type StateStreamLease struct {
@@ -206,14 +208,21 @@ func (lease StateStreamLease) updateState(receiver *Server, state *HostState, la
return false
}
}
lease.holder.reportSequence = nextServerReportSequence.Add(1)
return true
}
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
}
@@ -225,6 +234,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
}
@@ -364,7 +376,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.
@@ -372,6 +384,7 @@ type RuntimeSnapshot struct {
State *HostState
Host *Host
LastActive time.Time
ReportSequence uint64
PrevTransferInSnapshot uint64
PrevTransferOutSnapshot uint64
}
@@ -398,7 +411,7 @@ func (s *Server) RuntimeSnapshot() RuntimeSnapshot {
holder.host = cloneHost(s.Host)
}
}
return RuntimeSnapshot{State: cloneHostState(holder.state), Host: cloneHost(holder.host), LastActive: holder.lastActive, PrevTransferInSnapshot: holder.prevIn, PrevTransferOutSnapshot: holder.prevOut}
return RuntimeSnapshot{State: cloneHostState(holder.state), Host: cloneHost(holder.host), LastActive: holder.lastActive, ReportSequence: holder.reportSequence, PrevTransferInSnapshot: holder.prevIn, PrevTransferOutSnapshot: holder.prevOut}
}
func (s *Server) SetTransferSnapshots(inbound, outbound uint64) bool {
+27
View File
@@ -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)
}
+2
View File
@@ -35,6 +35,8 @@ type SettingForm struct {
AdminEmail string `json:"admin_email,omitempty" validate:"optional"`
DomainExpiryNotificationDays string `json:"domain_expiry_notification_days,omitempty" validate:"optional"`
ServerExpiryNotificationDays string `json:"server_expiry_notification_days,omitempty" validate:"optional"`
AllowJWTIPChange *bool `json:"allow_jwt_ip_change,omitempty" validate:"optional"`
OnlineStatsDays *int `json:"online_stats_days,omitempty" validate:"optional"`
}
type Setting struct {