diff --git a/constants/constants.go b/constants/constants.go new file mode 100644 index 0000000..1b3e115 --- /dev/null +++ b/constants/constants.go @@ -0,0 +1,13 @@ +package constants + +import "time" + +// time.Duration constants +const ( + // RecalculateTimeWindowHighSharesJob interval + RecalculateTimeWindowHighSharesJobInterval = 1 * time.Minute + // RecalculateTopSharesJob interval + RecalculateTopSharesJobInterval = 30 * time.Second + // IngestorWatchInterval interval + IngestorWatchInterval = 30 * time.Second +) diff --git a/database/db.go b/database/db.go index 57366ee..14ae07c 100644 --- a/database/db.go +++ b/database/db.go @@ -17,6 +17,8 @@ const ( TopSharesCollectionName = "TopShares" TopSharesAmount = 20 + + TimeWindowHighShareCollectionName = "TimeWindowHighShareStat" ) func InitDatabase(path string) (*clover.DB, error) { @@ -65,6 +67,21 @@ func InitDatabase(path string) (*clover.DB, error) { } } + // Init TimeWindowHighShareStat collection + hasTimeWindowCollection, err := db.HasCollection(TimeWindowHighShareCollectionName) + if err != nil { + return nil, fmt.Errorf("failed to check TimeWindowHighShare collection: %v", err) + } + + if !hasTimeWindowCollection { + if err := db.CreateCollection(TimeWindowHighShareCollectionName); err != nil { + return nil, fmt.Errorf("failed to create TimeWindowHighShare collection: %v", err) + } + if err := db.CreateIndex(TimeWindowHighShareCollectionName, "TimeWindowID"); err != nil { + return nil, fmt.Errorf("failed to create index for TimeWindowHighShare: %v", err) + } + } + return db, nil } @@ -165,3 +182,37 @@ func ReplaceTopShares(db *clover.DB, shares []models.ShareLog) { log.Printf("Replaced TopShares with %d shares", len(shares)) } + +func GetTimeWindowHighShares(db *clover.DB) []models.TimeWindowHighShare { + results, err := db.FindAll( + c.NewQuery(TimeWindowHighShareCollectionName). + Sort(c.SortOption{Field: "TimeWindowID", Direction: 1}), + ) + if err != nil { + log.Printf("failed to list time window high shares: %v", err) + return nil + } + + timeWindowHighShares := make([]models.TimeWindowHighShare, len(results)) + for idx, doc := range results { + var timeWindowHighShare models.TimeWindowHighShare + doc.Unmarshal(&timeWindowHighShare) + timeWindowHighShares[idx] = timeWindowHighShare + } + + return timeWindowHighShares +} + +func SetTimeWindowHighShare(db *clover.DB, share models.TimeWindowHighShare) error { + doc := document.NewDocumentOf(&share) + + existingDoc, _ := db.FindFirst(c.NewQuery(TimeWindowHighShareCollectionName). + Where(c.Field("TimeWindowID").Eq(share.TimeWindowID))) + if existingDoc != nil { + db.ReplaceById(TimeWindowHighShareCollectionName, existingDoc.ObjectId(), doc) + } else { + db.InsertOne(TimeWindowHighShareCollectionName, doc) + } + + return nil +} diff --git a/helpers/helpers.go b/helpers/helpers.go index b87237d..3521c75 100644 --- a/helpers/helpers.go +++ b/helpers/helpers.go @@ -35,5 +35,5 @@ func FormatCreateDate(createdate string) string { t := time.Unix(sec, nsec) return t.Format(time.DateTime) } - return "" + return "-" } diff --git a/ingest/ingest.go b/ingest/ingest.go index f387b35..a7cb233 100644 --- a/ingest/ingest.go +++ b/ingest/ingest.go @@ -12,6 +12,7 @@ import ( "github.com/ostafen/clover/v2" "github.com/ostafen/clover/v2/document" + "pool-stats/constants" "pool-stats/database" "pool-stats/models" ) @@ -26,7 +27,7 @@ func NewIngestor(db *clover.DB, path string) *Ingestor { } func (this *Ingestor) WatchAndIngest() { - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(constants.IngestorWatchInterval) defer ticker.Stop() for { diff --git a/jobs/recalculateTimeWindowHighShares.go b/jobs/recalculateTimeWindowHighShares.go new file mode 100644 index 0000000..3842515 --- /dev/null +++ b/jobs/recalculateTimeWindowHighShares.go @@ -0,0 +1,106 @@ +package jobs + +import ( + "pool-stats/constants" + "pool-stats/database" + "pool-stats/models" + "pool-stats/notlinq" + "sort" + "time" + + "github.com/ostafen/clover/v2" +) + +type RecalculateTimeWindowHighSharesJob struct { + DB *clover.DB +} + +func NewRecalculateTimeWindowHighSharesJob(db *clover.DB) *RecalculateTimeWindowHighSharesJob { + return &RecalculateTimeWindowHighSharesJob{DB: db} +} + +func (job *RecalculateTimeWindowHighSharesJob) Run() error { + ticker := time.NewTicker(constants.RecalculateTimeWindowHighSharesJobInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + job.recalculateTimeWindowHighShares() + } + } +} + +func (job *RecalculateTimeWindowHighSharesJob) recalculateTimeWindowHighShares() { + topShares := database.ListTopShares(job.DB) + sort.Slice(topShares, func(i, j int) bool { + return topShares[i].SDiff > topShares[j].SDiff + }) + + // All time high share + if len(topShares) > 0 { + allTimeHighShare := topShares[0] + allTimeHighShareStat := &models.TimeWindowHighShare{ + TimeWindowID: "0-all-time", + TimeWindowName: "All Time", + SDiff: allTimeHighShare.SDiff, + Time: allTimeHighShare.CreateDate, + } + database.SetTimeWindowHighShare(job.DB, *allTimeHighShareStat) + } + + // Other ranges + timeWindows := []struct { + ID string + Name string + Since time.Time + }{ + {"1-hour", "Past Hour", time.Now().Add(-1 * time.Hour)}, + {"2-day", "Past 24h", time.Now().Add(-24 * time.Hour)}, + {"3-week", "Past 7d", time.Now().Add(-7 * 24 * time.Hour)}, + } + for _, tw := range timeWindows { + // Can use one of top shares if in range, + // otherwise get highest share in range + var highestShare models.ShareLog + topSharesInRange := notlinq. + Where(topShares, func(s models.ShareLog) bool { + shareTime, err := s.ParseCreateDate() + if err != nil { + return false + } + return shareTime.After(tw.Since) + }) + sort.Slice(topSharesInRange, func(i, j int) bool { + return topSharesInRange[i].SDiff > topSharesInRange[j].SDiff + }) + if len(topSharesInRange) > 0 { + highestShare = topSharesInRange[0] + } else { + highestShareDocs, _ := database.GetHighestSharesInRange( + job.DB, database.CollectionName, tw.Since, 1) + if len(highestShareDocs) > 0 { + highestShare = highestShareDocs[0] + } + } + + var timeWindowStat models.TimeWindowHighShare + if highestShare.SDiff > 0 { + timeWindowStat = models.TimeWindowHighShare{ + TimeWindowID: tw.ID, + TimeWindowName: tw.Name, + SDiff: highestShare.SDiff, + Time: highestShare.CreateDate, + } + } else { + timeWindowStat = models.TimeWindowHighShare{ + TimeWindowID: tw.ID, + TimeWindowName: tw.Name, + SDiff: 0, + Time: "-", + } + } + + database.SetTimeWindowHighShare(job.DB, timeWindowStat) + } +} diff --git a/jobs/recalculateTopShares.go b/jobs/recalculateTopShares.go index 27225c7..633e474 100644 --- a/jobs/recalculateTopShares.go +++ b/jobs/recalculateTopShares.go @@ -1,6 +1,7 @@ package jobs import ( + "pool-stats/constants" "pool-stats/database" "pool-stats/helpers" "pool-stats/models" @@ -20,7 +21,7 @@ func NewRecalculateTopSharesJob(db *clover.DB) *RecalculateTopSharesJob { } func (job *RecalculateTopSharesJob) Run() error { - ticker := time.NewTicker(10 * time.Second) + ticker := time.NewTicker(constants.RecalculateTopSharesJobInterval) defer ticker.Stop() for { diff --git a/main.go b/main.go index 3eb7302..3c69130 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,9 @@ func main() { topSharesRecalcJob := jobs.NewRecalculateTopSharesJob(db) go topSharesRecalcJob.Run() + timeWindowHighSharesRecalcJob := jobs.NewRecalculateTimeWindowHighSharesJob(db) + go timeWindowHighSharesRecalcJob.Run() + webServer := web.NewWebServer(db, config.Port) if err := webServer.Start(); err != nil { log.Fatalf("Failed to start web server: %v", err) diff --git a/models/share_log.go b/models/share_log.go index 92c7baf..c5725ad 100644 --- a/models/share_log.go +++ b/models/share_log.go @@ -29,10 +29,11 @@ type ShareLog struct { Agent string `json:"agent"` // Miner agent string (e.g., bitaxe/BM1370) } -type ShareStat struct { - Label string - Diff string - Time string +type TimeWindowHighShare struct { + TimeWindowID string `json:"time_window_id"` // Unique ID for the time window + TimeWindowName string `json:"time_window_name"` // Name of the time window (e.g., "Past Hour") + SDiff float64 `json:"share_diff"` // Difficulty of the highest share + Time string `json:"share_time"` // Time of the highest share } // ParseCreateDate can be used to convert ShareLog.CreateDate to time.Time diff --git a/notlinq/notlinq.go b/notlinq/notlinq.go index eed76a0..8e13de9 100644 --- a/notlinq/notlinq.go +++ b/notlinq/notlinq.go @@ -12,3 +12,13 @@ func UniqueBy[T any, K comparable](items []T, keySelector func(T) K) []T { } return result } + +func Where[T any](items []T, predicate func(T) bool) []T { + result := make([]T, 0) + for _, item := range items { + if predicate(item) { + result = append(result, item) + } + } + return result +} diff --git a/stats/stats.go b/stats/stats.go deleted file mode 100644 index dfcc26b..0000000 --- a/stats/stats.go +++ /dev/null @@ -1,50 +0,0 @@ -package stats - -import ( - "time" - - "github.com/ostafen/clover/v2" - - "pool-stats/database" - "pool-stats/helpers" - "pool-stats/models" -) - -func GetStats(db *clover.DB) ([]models.ShareStat, error) { - now := time.Now() - ranges := []struct { - Label string - Since time.Time - }{ - {"Past Hour", now.Add(-1 * time.Hour)}, - {"Past 24h", now.Add(-24 * time.Hour)}, - {"Past 7d", now.Add(-7 * 24 * time.Hour)}, - } - - stats := []models.ShareStat{} - - // All-time highest - doc, _ := database.GetHighestSharesInRange(db, database.CollectionName, time.Unix(0, 0), 1) - if doc != nil { - stats = append(stats, models.ShareStat{ - Label: "All Time", - Diff: helpers.HumanDiff(doc[0].SDiff), - Time: helpers.ParseCreateDate(doc[0].CreateDate).Format(time.RFC822), - }) - } - - for _, r := range ranges { - doc, _ := database.GetHighestSharesInRange(db, database.CollectionName, r.Since, 1) - if doc != nil { - stats = append(stats, models.ShareStat{ - Label: r.Label, - Diff: helpers.HumanDiff(doc[0].SDiff), - Time: helpers.ParseCreateDate(doc[0].CreateDate).Format(time.RFC822), - }) - } else { - stats = append(stats, models.ShareStat{Label: r.Label, Diff: "-", Time: "-"}) - } - } - - return stats, nil -} diff --git a/templates/index.html b/templates/index.html index 4b386fd..6b931eb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,3 +1,4 @@ +{{define "index"}} @@ -45,13 +46,15 @@ Highest Share Diff Time - {{range .}} + {{ range .Stats }} - {{.Label}} - {{.Diff}} - {{.Time}} + {{ .TimeWindowName }} + + {{ if ne .SDiff 0.0 }} {{ humanDiff .SDiff }} {{ else }} - {{ end }} + + {{ formatCreateDate .Time }} - {{end}} + {{ end }} +{{ end }} diff --git a/web/handlers.go b/web/handlers.go deleted file mode 100644 index 08c6d4a..0000000 --- a/web/handlers.go +++ /dev/null @@ -1,19 +0,0 @@ -package web - -import ( - "html/template" - "net/http" - - "pool-stats/stats" -) - -func (ws *WebServer) IndexHandler(w http.ResponseWriter, r *http.Request) { - shareStats, err := stats.GetStats(ws.db) - if err != nil { - http.Error(w, "Failed to load stats", 500) - return - } - - tmpl := template.Must(template.ParseFiles("templates/index.html")) - tmpl.Execute(w, shareStats) -} diff --git a/web/indexHandler.go b/web/indexHandler.go new file mode 100644 index 0000000..c21c802 --- /dev/null +++ b/web/indexHandler.go @@ -0,0 +1,41 @@ +package web + +import ( + "html/template" + "net/http" + + "pool-stats/database" + "pool-stats/helpers" + "pool-stats/models" +) + +type IndexPageData struct { + Stats []models.TimeWindowHighShare +} + +func (ws *WebServer) IndexHandler(w http.ResponseWriter, r *http.Request) { + tmpl := template.New("index").Funcs(template.FuncMap{ + "humanDiff": helpers.HumanDiff, + "formatCreateDate": helpers.FormatCreateDate, + }) + tmpl, err := tmpl.ParseFiles("templates/index.html") + if err != nil { + http.Error(w, "Failed to load template", 500) + return + } + + tws := database.GetTimeWindowHighShares(ws.db) + if tws == nil { + http.Error(w, "Failed to load time window high shares", 500) + return + } + + indexData := IndexPageData{ + Stats: tws, + } + if err := tmpl.Execute(w, indexData); err != nil { + http.Error(w, "Failed to render template", 500) + println("Error rendering template:", err.Error()) + return + } +}