diff --git a/database/db.go b/database/db.go index 516c2cb..57366ee 100644 --- a/database/db.go +++ b/database/db.go @@ -14,6 +14,9 @@ import ( const ( CollectionName = "shares" + + TopSharesCollectionName = "TopShares" + TopSharesAmount = 20 ) func InitDatabase(path string) (*clover.DB, error) { @@ -42,10 +45,30 @@ func InitDatabase(path string) (*clover.DB, error) { } } + // Init TopShares collection + hasTopSharesCollection, err := db.HasCollection(TopSharesCollectionName) + if err != nil { + return nil, fmt.Errorf("failed to check TopShares collection: %v", err) + } + + if !hasTopSharesCollection { + if err := db.CreateCollection(TopSharesCollectionName); err != nil { + return nil, fmt.Errorf("failed to create TopShares collection: %v", err) + } + + if err := db.CreateIndex(TopSharesCollectionName, "CreateDate"); err != nil { + return nil, fmt.Errorf("failed to create index for TopShares: %v", err) + } + + if err := db.CreateIndex(TopSharesCollectionName, "SDiff"); err != nil { + return nil, fmt.Errorf("failed to create index for TopShares SDiff: %v", err) + } + } + return db, nil } -func GetHighestShareInRange(db *clover.DB, collection string, since time.Time) (*document.Document, error) { +func GetHighestSharesInRange(db *clover.DB, collection string, since time.Time, count int) ([]models.ShareLog, error) { // Convert `since` to the format in `createdate` lower := since.Unix() upper := time.Now().Unix() @@ -58,12 +81,22 @@ func GetHighestShareInRange(db *clover.DB, collection string, since time.Time) ( results, err := db.FindAll(c.NewQuery(collection). Where(criteria). Sort(c.SortOption{Field: "SDiff", Direction: -1}). - Limit(1)) + Limit(count)) if err != nil || len(results) == 0 { return nil, err } - return results[0], nil + + var shares []models.ShareLog + for _, doc := range results { + var s models.ShareLog + if err := doc.Unmarshal(&s); err != nil { + return nil, err + } + shares = append(shares, s) + } + + return shares, nil } func PrintAllHashes(db *clover.DB) { @@ -99,3 +132,36 @@ func ListShares(db *clover.DB, offset int, count int) []models.ShareLog { return shareLogs } + +func ListTopShares(db *clover.DB) []models.ShareLog { + results, err := db.FindAll( + c.NewQuery(TopSharesCollectionName). + Sort(c.SortOption{Field: "SDiff", Direction: -1}), + ) + if err != nil { + log.Printf("failed to list top shares: %v", err) + return nil + } + + topShares := make([]models.ShareLog, len(results)) + for idx, doc := range results { + var shareLog models.ShareLog + doc.Unmarshal(&shareLog) + topShares[idx] = shareLog + } + + return topShares +} + +func ReplaceTopShares(db *clover.DB, shares []models.ShareLog) { + db.Delete(c.NewQuery(TopSharesCollectionName)) + + for _, share := range shares { + doc := document.NewDocumentOf(&share) + if _, err := db.InsertOne(TopSharesCollectionName, doc); err != nil { + return + } + } + + log.Printf("Replaced TopShares with %d shares", len(shares)) +} diff --git a/jobs/recalculateTopShares.go b/jobs/recalculateTopShares.go new file mode 100644 index 0000000..27225c7 --- /dev/null +++ b/jobs/recalculateTopShares.go @@ -0,0 +1,60 @@ +package jobs + +import ( + "pool-stats/database" + "pool-stats/helpers" + "pool-stats/models" + "pool-stats/notlinq" + "sort" + "time" + + "github.com/ostafen/clover/v2" +) + +type RecalculateTopSharesJob struct { + DB *clover.DB +} + +func NewRecalculateTopSharesJob(db *clover.DB) *RecalculateTopSharesJob { + return &RecalculateTopSharesJob{DB: db} +} + +func (job *RecalculateTopSharesJob) Run() error { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + <-ticker.C + job.recalculateTopShares() + } +} + +func (job *RecalculateTopSharesJob) recalculateTopShares() { + topSharesAmount := database.TopSharesAmount + currentTopShares := database.ListTopShares(job.DB) + + var newTopShares []models.ShareLog + if currentTopShares == nil || len(currentTopShares) < topSharesAmount { + newTopShares, _ = database.GetHighestSharesInRange(job.DB, database.CollectionName, time.Unix(0, 0), topSharesAmount) + } else { + sort.Slice(currentTopShares, func(i, j int) bool { + return currentTopShares[i].CreateDate > currentTopShares[j].CreateDate + }) + lastTopShareDate := currentTopShares[0].CreateDate + lastTopShareDateTime := helpers.ParseCreateDate(lastTopShareDate) + newTopShares, _ = database.GetHighestSharesInRange(job.DB, database.CollectionName, lastTopShareDateTime, topSharesAmount) + } + + newTopShares = append(newTopShares, currentTopShares...) + sort.Slice(newTopShares, func(i, j int) bool { + return newTopShares[i].SDiff > newTopShares[j].SDiff + }) + newTopShares = notlinq.UniqueBy(newTopShares, func(s models.ShareLog) string { + return s.Hash + }) + if len(newTopShares) > topSharesAmount { + newTopShares = newTopShares[:topSharesAmount] + } + + database.ReplaceTopShares(job.DB, newTopShares) +} diff --git a/main.go b/main.go index 6aba21c..3eb7302 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "pool-stats/config" "pool-stats/database" "pool-stats/ingest" + "pool-stats/jobs" "pool-stats/web" ) @@ -25,6 +26,9 @@ func main() { ingestor := ingest.NewIngestor(db, config.LogPath) go ingestor.WatchAndIngest() + topSharesRecalcJob := jobs.NewRecalculateTopSharesJob(db) + go topSharesRecalcJob.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/notlinq/notlinq.go b/notlinq/notlinq.go new file mode 100644 index 0000000..eed76a0 --- /dev/null +++ b/notlinq/notlinq.go @@ -0,0 +1,14 @@ +package notlinq + +func UniqueBy[T any, K comparable](items []T, keySelector func(T) K) []T { + seen := make(map[K]struct{}) + result := make([]T, 0, len(items)) + for _, item := range items { + key := keySelector(item) + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} + result = append(result, item) + } + } + return result +} diff --git a/stats/stats.go b/stats/stats.go index 4b4fcdb..dfcc26b 100644 --- a/stats/stats.go +++ b/stats/stats.go @@ -24,22 +24,22 @@ func GetStats(db *clover.DB) ([]models.ShareStat, error) { stats := []models.ShareStat{} // All-time highest - doc, _ := database.GetHighestShareInRange(db, database.CollectionName, time.Unix(0, 0)) + 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.Get("SDiff").(float64)), - Time: helpers.ParseCreateDate(doc.Get("CreateDate").(string)).Format(time.RFC822), + Diff: helpers.HumanDiff(doc[0].SDiff), + Time: helpers.ParseCreateDate(doc[0].CreateDate).Format(time.RFC822), }) } for _, r := range ranges { - doc, _ := database.GetHighestShareInRange(db, database.CollectionName, r.Since) + doc, _ := database.GetHighestSharesInRange(db, database.CollectionName, r.Since, 1) if doc != nil { stats = append(stats, models.ShareStat{ Label: r.Label, - Diff: helpers.HumanDiff(doc.Get("SDiff").(float64)), - Time: helpers.ParseCreateDate(doc.Get("CreateDate").(string)).Format(time.RFC822), + 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: "-"}) diff --git a/templates/index.html b/templates/index.html index f3532b5..4b386fd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -57,6 +57,7 @@ diff --git a/templates/shares.html b/templates/shares.html index b903d54..ede3665 100644 --- a/templates/shares.html +++ b/templates/shares.html @@ -97,6 +97,7 @@ diff --git a/templates/top_shares.html b/templates/top_shares.html new file mode 100644 index 0000000..197856a --- /dev/null +++ b/templates/top_shares.html @@ -0,0 +1,84 @@ +{{ define "top_shares" }} + + + + + ckpool Top Shares + + + +

☀️ Pool Top Shares

+ + + + + + + + + + + + + {{ range .Shares }} + + + + + + + + {{ else }} + + + + {{ end }} + +
TimeWorkerAddressSDiffHash
{{ formatCreateDate .CreateDate }}{{ .WorkerName }}{{ .Address }}{{ humanDiff .SDiff }}{{ .Hash }}
No shares found.
+ + + +{{ end }} diff --git a/web/server.go b/web/server.go index 385190a..73b0fee 100644 --- a/web/server.go +++ b/web/server.go @@ -23,6 +23,7 @@ func NewWebServer(db *clover.DB, port int) *WebServer { func (ws *WebServer) Start() error { http.HandleFunc("/", ws.IndexHandler) http.HandleFunc("/shares", ws.SharesHandler) + http.HandleFunc("/top-shares", ws.TopSharesHandler) address := ":" + fmt.Sprint(ws.port) println("Listening on", address) diff --git a/web/topSharesHandler.go b/web/topSharesHandler.go new file mode 100644 index 0000000..6e3b692 --- /dev/null +++ b/web/topSharesHandler.go @@ -0,0 +1,39 @@ +package web + +import ( + "html/template" + "net/http" + "pool-stats/database" + "pool-stats/helpers" + "pool-stats/models" +) + +type TopSharesPageData struct { + Shares []models.ShareLog +} + +func (ws *WebServer) TopSharesHandler(w http.ResponseWriter, r *http.Request) { + tmpl := template.New("top_shares").Funcs(template.FuncMap{ + "humanDiff": helpers.HumanDiff, + "formatCreateDate": helpers.FormatCreateDate, + }) + tmpl, err := tmpl.ParseFiles("templates/top_shares.html") + if err != nil { + http.Error(w, "Failed to load template", 500) + return + } + + topShares := database.ListTopShares(ws.db) + if topShares == nil { + http.Error(w, "Failed to load top shares", 500) + return + } + + data := TopSharesPageData{ + Shares: topShares, + } + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, "Failed to render template", 500) + return + } +}