adds historial hashrate data that reduces from 10s res to 1m res after a rolling 5m window.

This commit is contained in:
Snider 2025-11-09 04:08:10 +00:00
parent 6ac80d211a
commit a043a09d22
7 changed files with 409 additions and 42 deletions

View file

@ -145,6 +145,38 @@ const docTemplate = `{
}
}
},
"/miners/{miner_name}/hashrate-history": {
"get": {
"description": "Get historical hashrate data for a running miner",
"produces": [
"application/json"
],
"tags": [
"miners"
],
"summary": "Get miner hashrate history",
"parameters": [
{
"type": "string",
"description": "Miner Name",
"name": "miner_name",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/mining.HashratePoint"
}
}
}
}
}
},
"/miners/{miner_name}/stats": {
"get": {
"description": "Get statistics for a running miner",
@ -531,6 +563,17 @@ const docTemplate = `{
}
}
},
"mining.HashratePoint": {
"type": "object",
"properties": {
"hashrate": {
"type": "integer"
},
"timestamp": {
"type": "string"
}
}
},
"mining.InstallationDetails": {
"type": "object",
"properties": {
@ -613,9 +656,23 @@ const docTemplate = `{
"configPath": {
"type": "string"
},
"hashrateHistory": {
"description": "High-resolution (10s)",
"type": "array",
"items": {
"$ref": "#/definitions/mining.HashratePoint"
}
},
"lastHeartbeat": {
"type": "integer"
},
"lowResHashrateHistory": {
"description": "Low-resolution (1m averages)",
"type": "array",
"items": {
"$ref": "#/definitions/mining.HashratePoint"
}
},
"miner_binary": {
"description": "New field for the full path to the miner executable",
"type": "string"

View file

@ -139,6 +139,38 @@
}
}
},
"/miners/{miner_name}/hashrate-history": {
"get": {
"description": "Get historical hashrate data for a running miner",
"produces": [
"application/json"
],
"tags": [
"miners"
],
"summary": "Get miner hashrate history",
"parameters": [
{
"type": "string",
"description": "Miner Name",
"name": "miner_name",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/mining.HashratePoint"
}
}
}
}
}
},
"/miners/{miner_name}/stats": {
"get": {
"description": "Get statistics for a running miner",
@ -525,6 +557,17 @@
}
}
},
"mining.HashratePoint": {
"type": "object",
"properties": {
"hashrate": {
"type": "integer"
},
"timestamp": {
"type": "string"
}
}
},
"mining.InstallationDetails": {
"type": "object",
"properties": {
@ -607,9 +650,23 @@
"configPath": {
"type": "string"
},
"hashrateHistory": {
"description": "High-resolution (10s)",
"type": "array",
"items": {
"$ref": "#/definitions/mining.HashratePoint"
}
},
"lastHeartbeat": {
"type": "integer"
},
"lowResHashrateHistory": {
"description": "Low-resolution (1m averages)",
"type": "array",
"items": {
"$ref": "#/definitions/mining.HashratePoint"
}
},
"miner_binary": {
"description": "New field for the full path to the miner executable",
"type": "string"

View file

@ -153,6 +153,13 @@ definitions:
wallet:
type: string
type: object
mining.HashratePoint:
properties:
hashrate:
type: integer
timestamp:
type: string
type: object
mining.InstallationDetails:
properties:
is_installed:
@ -207,8 +214,18 @@ definitions:
$ref: '#/definitions/mining.API'
configPath:
type: string
hashrateHistory:
description: High-resolution (10s)
items:
$ref: '#/definitions/mining.HashratePoint'
type: array
lastHeartbeat:
type: integer
lowResHashrateHistory:
description: Low-resolution (1m averages)
items:
$ref: '#/definitions/mining.HashratePoint'
type: array
miner_binary:
description: New field for the full path to the miner executable
type: string
@ -303,6 +320,27 @@ paths:
summary: Stop a running miner
tags:
- miners
/miners/{miner_name}/hashrate-history:
get:
description: Get historical hashrate data for a running miner
parameters:
- description: Miner Name
in: path
name: miner_name
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/mining.HashratePoint'
type: array
summary: Get miner hashrate history
tags:
- miners
/miners/{miner_name}/stats:
get:
description: Get statistics for a running miner

View file

@ -3,22 +3,34 @@ package mining
import (
"fmt"
"strings"
"sync"
"time"
)
// Manager handles miner lifecycle and operations
type Manager struct {
miners map[string]Miner
miners map[string]Miner
mu sync.RWMutex // Mutex to protect the miners map
stopChan chan struct{}
waitGroup sync.WaitGroup
}
// NewManager creates a new miner manager
func NewManager() *Manager {
return &Manager{
miners: make(map[string]Miner),
m := &Manager{
miners: make(map[string]Miner),
stopChan: make(chan struct{}),
waitGroup: sync.WaitGroup{},
}
m.startStatsCollection()
return m
}
// StartMiner starts a new miner with the given configuration
func (m *Manager) StartMiner(minerType string, config *Config) (Miner, error) {
m.mu.Lock()
defer m.mu.Unlock()
var miner Miner
switch strings.ToLower(minerType) {
case "xmrig":
@ -43,6 +55,9 @@ func (m *Manager) StartMiner(minerType string, config *Config) (Miner, error) {
// StopMiner stops a running miner
func (m *Manager) StopMiner(name string) error {
m.mu.Lock()
defer m.mu.Unlock()
minerKey := strings.ToLower(name) // Normalize input name to lowercase
miner, exists := m.miners[minerKey]
if !exists {
@ -59,6 +74,9 @@ func (m *Manager) StopMiner(name string) error {
// GetMiner retrieves a miner by ID
func (m *Manager) GetMiner(name string) (Miner, error) {
m.mu.RLock()
defer m.mu.RUnlock()
minerKey := strings.ToLower(name) // Normalize input name to lowercase
miner, exists := m.miners[minerKey]
if !exists {
@ -69,6 +87,9 @@ func (m *Manager) GetMiner(name string) (Miner, error) {
// ListMiners returns all miners
func (m *Manager) ListMiners() []Miner {
m.mu.RLock()
defer m.mu.RUnlock()
miners := make([]Miner, 0, len(m.miners))
for _, miner := range m.miners {
miners = append(miners, miner)
@ -85,3 +106,66 @@ func (m *Manager) ListAvailableMiners() []AvailableMiner {
},
}
}
// startStatsCollection starts a goroutine to periodically collect stats from active miners
func (m *Manager) startStatsCollection() {
m.waitGroup.Add(1)
go func() {
defer m.waitGroup.Done()
ticker := time.NewTicker(HighResolutionInterval) // Collect stats every 10 seconds
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.collectMinerStats()
case <-m.stopChan:
return
}
}
}()
}
// collectMinerStats iterates through active miners and collects their stats
func (m *Manager) collectMinerStats() {
m.mu.RLock()
minersToCollect := make([]Miner, 0, len(m.miners))
for _, miner := range m.miners {
minersToCollect = append(minersToCollect, miner)
}
m.mu.RUnlock()
now := time.Now()
for _, miner := range minersToCollect {
stats, err := miner.GetStats()
if err != nil {
// Log the error but don't stop the collection for other miners
fmt.Printf("Error getting stats for miner %s: %v\n", miner.GetName(), err)
continue
}
miner.AddHashratePoint(HashratePoint{
Timestamp: now,
Hashrate: stats.Hashrate,
})
miner.ReduceHashrateHistory(now) // Call the reducer
}
}
// GetMinerHashrateHistory returns the hashrate history for a specific miner
func (m *Manager) GetMinerHashrateHistory(name string) ([]HashratePoint, error) {
m.mu.RLock()
defer m.mu.RUnlock()
minerKey := strings.ToLower(name)
miner, exists := m.miners[minerKey]
if !exists {
return nil, fmt.Errorf("miner not found: %s", name)
}
return miner.GetHashrateHistory(), nil
}
// Stop stops the manager and its background goroutines
func (m *Manager) Stop() {
close(m.stopChan)
m.waitGroup.Wait() // Wait for the stats collection goroutine to finish
}

View file

@ -9,6 +9,17 @@ import (
"github.com/gin-gonic/gin"
)
const (
// HighResolutionDuration is the duration for which hashrate data is kept at high resolution (10s intervals)
HighResolutionDuration = 5 * time.Minute
// HighResolutionInterval is the interval at which hashrate data is collected for high resolution
HighResolutionInterval = 10 * time.Second
// LowResolutionInterval is the interval for aggregated hashrate data (1m averages)
LowResolutionInterval = 1 * time.Minute
// LowResHistoryRetention is the duration for which low-resolution hashrate data is retained
LowResHistoryRetention = 24 * time.Hour // Example: keep 24 hours of 1-minute averages
)
// Miner is the interface for a miner
type Miner interface {
Install() error
@ -20,6 +31,9 @@ type Miner interface {
GetPath() string
CheckInstallation() (*InstallationDetails, error)
GetLatestVersion() (string, error)
GetHashrateHistory() []HashratePoint // New method to get hashrate history
AddHashratePoint(point HashratePoint) // New method to add a hashrate point
ReduceHashrateHistory(now time.Time) // New method to trigger history reduction
}
// InstallationDetails contains information about an installed miner
@ -146,19 +160,28 @@ type History struct {
Updated int64 `json:"updated"`
}
// HashratePoint represents a single hashrate measurement at a specific time
type HashratePoint struct {
Timestamp time.Time `json:"timestamp"`
Hashrate int `json:"hashrate"`
}
// XMRigMiner represents an XMRig miner
type XMRigMiner struct {
Name string `json:"name"`
Version string `json:"version"`
URL string `json:"url"`
Path string `json:"path"` // This will now be the versioned folder path
MinerBinary string `json:"miner_binary"` // New field for the full path to the miner executable
Running bool `json:"running"`
LastHeartbeat int64 `json:"lastHeartbeat"`
ConfigPath string `json:"configPath"`
API *API `json:"api"`
mu sync.Mutex
cmd *exec.Cmd `json:"-"`
Name string `json:"name"`
Version string `json:"version"`
URL string `json:"url"`
Path string `json:"path"` // This will now be the versioned folder path
MinerBinary string `json:"miner_binary"` // New field for the full path to the miner executable
Running bool `json:"running"`
LastHeartbeat int64 `json:"lastHeartbeat"`
ConfigPath string `json:"configPath"`
API *API `json:"api"`
mu sync.Mutex
cmd *exec.Cmd `json:"-"`
HashrateHistory []HashratePoint `json:"hashrateHistory"` // High-resolution (10s)
LowResHashrateHistory []HashratePoint `json:"lowResHashrateHistory"` // Low-resolution (1m averages)
LastLowResAggregation time.Time `json:"-"` // Timestamp of the last low-res aggregation
}
// API represents the XMRig API configuration

View file

@ -61,6 +61,9 @@ func (s *Service) ServiceStartup(ctx context.Context) error {
go func() {
<-ctx.Done()
// Stop the manager's background goroutines
s.Manager.Stop()
ctxShutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Server.Shutdown(ctxShutdown); err != nil {
@ -88,6 +91,7 @@ func (s *Service) setupRoutes() {
minersGroup.DELETE("/:miner_name/uninstall", s.handleUninstallMiner)
minersGroup.DELETE("/:miner_name", s.handleStopMiner)
minersGroup.GET("/:miner_name/stats", s.handleGetMinerStats)
minersGroup.GET("/:miner_name/hashrate-history", s.handleGetMinerHashrateHistory) // New endpoint
}
}
@ -389,3 +393,21 @@ func (s *Service) handleGetMinerStats(c *gin.Context) {
}
c.JSON(http.StatusOK, stats)
}
// handleGetMinerHashrateHistory godoc
// @Summary Get miner hashrate history
// @Description Get historical hashrate data for a running miner
// @Tags miners
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Success 200 {array} HashratePoint
// @Router /miners/{miner_name}/hashrate-history [get]
func (s *Service) handleGetMinerHashrateHistory(c *gin.Context) {
minerName := c.Param("miner_name")
history, err := s.Manager.GetMinerHashrateHistory(minerName)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, history)
}

View file

@ -14,6 +14,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
@ -35,6 +36,9 @@ func NewXMRigMiner() *XMRigMiner {
ListenHost: "127.0.0.1",
ListenPort: 9000,
},
HashrateHistory: make([]HashratePoint, 0),
LowResHashrateHistory: make([]HashratePoint, 0),
LastLowResAggregation: time.Now(),
}
}
@ -343,7 +347,6 @@ func (m *XMRigMiner) Start(config *Config) error {
if config.CPUNoYield {
args = append(args, "--cpu-no-yield")
}
// HugePages is handled by config file, but --no-huge-pages is a CLI option
if !config.HugePages { // If HugePages is explicitly false in config, add --no-huge-pages
args = append(args, "--no-huge-pages")
}
@ -382,15 +385,13 @@ func (m *XMRigMiner) Start(config *Config) error {
}
// API options (CLI options override config file and m.API defaults)
// The API settings in m.API are used for GetStats, but CLI options can override for starting the miner
if m.API.Enabled { // Only add API related CLI args if API is generally enabled
if m.API.Enabled {
if config.APIWorkerID != "" {
args = append(args, "--api-worker-id", config.APIWorkerID)
}
if config.APIID != "" {
args = append(args, "--api-id", config.APIID)
}
// Prefer config.HTTPHost/Port, fallback to m.API, then to XMRig defaults
if config.HTTPHost != "" {
args = append(args, "--http-host", config.HTTPHost)
} else {
@ -467,12 +468,10 @@ func (m *XMRigMiner) Start(config *Config) error {
args = append(args, "--no-dmi")
}
// Print the command being executed for debugging
fmt.Fprintf(os.Stderr, "Executing XMRig command: %s %s\n", m.MinerBinary, strings.Join(args, " "))
m.cmd = exec.Command(m.MinerBinary, args...)
// If LogOutput is true, redirect stdout and stderr
if config.LogOutput {
m.cmd.Stdout = os.Stdout
m.cmd.Stderr = os.Stderr
@ -504,7 +503,6 @@ func (m *XMRigMiner) Stop() error {
return errors.New("miner is not running")
}
// Kill the process. The goroutine in Start() will handle Wait() and state change.
return m.cmd.Process.Kill()
}
@ -547,10 +545,118 @@ func (m *XMRigMiner) GetStats() (*PerformanceMetrics, error) {
}, nil
}
// GetHashrateHistory returns the combined high-resolution and low-resolution hashrate history.
func (m *XMRigMiner) GetHashrateHistory() []HashratePoint {
m.mu.Lock()
defer m.mu.Unlock()
// Combine low-res and high-res history
combinedHistory := make([]HashratePoint, 0, len(m.LowResHashrateHistory)+len(m.HashrateHistory))
combinedHistory = append(combinedHistory, m.LowResHashrateHistory...)
combinedHistory = append(combinedHistory, m.HashrateHistory...)
return combinedHistory
}
// AddHashratePoint adds a new hashrate measurement to the high-resolution history.
func (m *XMRigMiner) AddHashratePoint(point HashratePoint) {
m.mu.Lock()
defer m.mu.Unlock()
m.HashrateHistory = append(m.HashrateHistory, point)
// No trimming here; trimming is handled by ReduceHashrateHistory
}
// ReduceHashrateHistory aggregates older high-resolution data into 1-minute averages
// and adds them to the low-resolution history.
func (m *XMRigMiner) ReduceHashrateHistory(now time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
// Only aggregate if enough time has passed since the last aggregation
// or if it's the first aggregation
if !m.LastLowResAggregation.IsZero() && now.Sub(m.LastLowResAggregation) < LowResolutionInterval {
return
}
// Find points in HashrateHistory that are older than HighResolutionDuration
// These are the candidates for aggregation into low-resolution history.
var pointsToAggregate []HashratePoint
var newHighResHistory []HashratePoint
// The cutoff is exclusive: points *at or before* this time are candidates for aggregation.
// We want to aggregate points that are *strictly older* than HighResolutionDuration ago.
// So, if HighResolutionDuration is 5 minutes, points older than (now - 5 minutes) are aggregated.
cutoff := now.Add(-HighResolutionDuration)
for _, p := range m.HashrateHistory {
if p.Timestamp.Before(cutoff) { // Use Before to ensure strict older-than
pointsToAggregate = append(pointsToAggregate, p)
} else {
newHighResHistory = append(newHighResHistory, p)
}
}
m.HashrateHistory = newHighResHistory // Update high-res history to only contain recent points
if len(pointsToAggregate) == 0 {
// If no points to aggregate, just update LastLowResAggregation and return
m.LastLowResAggregation = now
return
}
// Aggregate into 1-minute slices
// Group points by minute (truncated timestamp)
minuteGroups := make(map[time.Time][]int)
for _, p := range pointsToAggregate {
// Round timestamp down to the nearest minute for grouping
minute := p.Timestamp.Truncate(LowResolutionInterval)
minuteGroups[minute] = append(minuteGroups[minute], p.Hashrate)
}
// Calculate average for each minute and add to low-res history
var newLowResPoints []HashratePoint
for minute, hashrates := range minuteGroups {
if len(hashrates) > 0 {
totalHashrate := 0
for _, hr := range hashrates {
totalHashrate += hr
}
avgHashrate := totalHashrate / len(hashrates)
newLowResPoints = append(newLowResPoints, HashratePoint{
Timestamp: minute,
Hashrate: avgHashrate,
})
}
}
// Sort new low-res points by timestamp to maintain chronological order
sort.Slice(newLowResPoints, func(i, j int) bool {
return newLowResPoints[i].Timestamp.Before(newLowResPoints[j].Timestamp)
})
m.LowResHashrateHistory = append(m.LowResHashrateHistory, newLowResPoints...)
// Trim low-resolution history to LowResHistoryRetention
lowResCutoff := now.Add(-LowResHistoryRetention)
// Find the first point that is *after* or equal to the lowResCutoff
firstValidLowResIndex := 0
for i, p := range m.LowResHashrateHistory {
if p.Timestamp.After(lowResCutoff) || p.Timestamp.Equal(lowResCutoff) {
firstValidLowResIndex = i
break
}
if i == len(m.LowResHashrateHistory)-1 { // All points are older than cutoff
firstValidLowResIndex = len(m.LowResHashrateHistory) // Clear all
}
}
m.LowResHashrateHistory = m.LowResHashrateHistory[firstValidLowResIndex:]
m.LastLowResAggregation = now
}
func (m *XMRigMiner) createConfig(config *Config) error {
configPath, err := xdg.ConfigFile("lethean-desktop/xmrig.json")
if err != nil {
// Fallback to home directory if XDG is not available
homeDir, err := os.UserHomeDir()
if err != nil {
return err
@ -563,7 +669,6 @@ func (m *XMRigMiner) createConfig(config *Config) error {
return err
}
// Create the config
c := map[string]interface{}{
"api": map[string]interface{}{
"enabled": m.API.Enabled,
@ -587,7 +692,6 @@ func (m *XMRigMiner) createConfig(config *Config) error {
},
}
// Write the config to the file
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
@ -603,21 +707,17 @@ func (m *XMRigMiner) unzip(src, dest string) error {
defer r.Close()
for _, f := range r.File {
// Store filename/path for returning and using later on
fpath := filepath.Join(dest, f.Name)
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
return fmt.Errorf("%s: illegal file path", fpath)
}
if f.FileInfo().IsDir() {
// Make Folder
os.MkdirAll(fpath, os.ModePerm)
continue
}
// Make File
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return err
}
@ -634,7 +734,6 @@ func (m *XMRigMiner) unzip(src, dest string) error {
_, err = io.Copy(outFile, rc)
// Close the file without defer to close before next iteration of loop
outFile.Close()
rc.Close()
@ -664,20 +763,14 @@ func (m *XMRigMiner) untar(src, dest string) error {
header, err := tr.Next()
switch {
// if no more files are found return
case err == io.EOF:
return nil
// return any other error
case err != nil:
return err
// if the header is nil, just skip it (not sure how this happens)
case header == nil:
continue
}
// Sanitize the header name to prevent path traversal
cleanedName := filepath.Clean(header.Name)
if strings.HasPrefix(cleanedName, "..") || strings.HasPrefix(cleanedName, "/") || cleanedName == "." {
continue
@ -689,18 +782,13 @@ func (m *XMRigMiner) untar(src, dest string) error {
continue
}
// check the file type
switch header.Typeflag {
// if its a dir and it doesn't exist create it
case tar.TypeDir:
if _, err := os.Stat(target); err != nil {
if err := os.MkdirAll(target, 0755); err != nil {
return err
}
}
// if it's a file create it
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
@ -710,12 +798,10 @@ func (m *XMRigMiner) untar(src, dest string) error {
return err
}
// copy over contents
if _, err := io.Copy(f, tr); err != nil {
return err
}
// manually close here after each file operation; defering would cause each file to wait until all operations have completed.
f.Close()
}
}