diff --git a/docs/docs.go b/docs/docs.go index 4ca1eaf..a75f0a6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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" diff --git a/docs/swagger.json b/docs/swagger.json index fbea780..666cedf 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ff3a3f6..04a3edf 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/pkg/mining/manager.go b/pkg/mining/manager.go index 12c3607..ac6ab6d 100644 --- a/pkg/mining/manager.go +++ b/pkg/mining/manager.go @@ -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 +} diff --git a/pkg/mining/mining.go b/pkg/mining/mining.go index e31cfaf..baebd87 100644 --- a/pkg/mining/mining.go +++ b/pkg/mining/mining.go @@ -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 diff --git a/pkg/mining/service.go b/pkg/mining/service.go index 3de73b6..9039140 100644 --- a/pkg/mining/service.go +++ b/pkg/mining/service.go @@ -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) +} diff --git a/pkg/mining/xmrig.go b/pkg/mining/xmrig.go index c5ddbeb..f15d8e9 100644 --- a/pkg/mining/xmrig.go +++ b/pkg/mining/xmrig.go @@ -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() } }