mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 16:40:21 +00:00
Merge upstream monitoring features preserving domain extensions and require fixed Go toolchain
This commit is contained in:
commit
71dd21269b
92 files changed
+8557
-874
No files matched your search
@@ -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(©)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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...)
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
+223
-114
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user