feat: Enhance mining configuration management and API documentation

This commit is contained in:
Snider 2025-12-07 16:26:18 +00:00
parent 2576d4bc1b
commit 816f860b73
18 changed files with 796 additions and 272 deletions

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/Snider/Mining/pkg/mining"
"github.com/adrg/xdg"
@ -53,7 +54,6 @@ func loadAndDisplayCache() (bool, error) {
cacheBytes, err := os.ReadFile(configPath)
if err != nil {
// If the cache file is missing (e.g., after an uninstall), it's not a fatal error
if os.IsNotExist(err) {
fmt.Println("No cached data found. Run 'install' for a miner first.")
return false, nil
@ -61,16 +61,19 @@ func loadAndDisplayCache() (bool, error) {
return false, fmt.Errorf("could not read cache file from %s: %w", configPath, err)
}
var cachedDetails []*mining.InstallationDetails
if err := json.Unmarshal(cacheBytes, &cachedDetails); err != nil {
var systemInfo mining.SystemInfo
if err := json.Unmarshal(cacheBytes, &systemInfo); err != nil {
return false, fmt.Errorf("could not parse cache file: %w", err)
}
for _, details := range cachedDetails {
fmt.Printf("System Info (cached at %s):\n", systemInfo.Timestamp.Format(time.RFC1123))
fmt.Printf(" OS: %s, Arch: %s\n", systemInfo.OS, systemInfo.Architecture)
fmt.Println()
for _, details := range systemInfo.InstalledMinersInfo {
// Infer miner name from path for display purposes
var minerName string
if details.Path != "" { // Use path to infer miner name if available
// This is a weak heuristic, but works for now.
// A more robust solution would store miner name in InstallationDetails.
if details.Path != "" {
if strings.Contains(details.Path, "xmrig") {
minerName = "XMRig"
} else {
@ -85,15 +88,7 @@ func loadAndDisplayCache() (bool, error) {
return true, nil
}
func saveResultsToCache(details []*mining.InstallationDetails) error {
// Filter out non-installed miners before saving
var installedOnly []*mining.InstallationDetails
for _, d := range details {
if d.IsInstalled {
installedOnly = append(installedOnly, d)
}
}
func saveResultsToCache(systemInfo *mining.SystemInfo) error {
configDir, err := xdg.ConfigFile("lethean-desktop/miners")
if err != nil {
return fmt.Errorf("could not get config directory: %w", err)
@ -103,7 +98,7 @@ func saveResultsToCache(details []*mining.InstallationDetails) error {
}
configPath := filepath.Join(configDir, "config.json")
data, err := json.MarshalIndent(installedOnly, "", " ")
data, err := json.MarshalIndent(systemInfo, "", " ")
if err != nil {
return fmt.Errorf("could not marshal cache data: %w", err)
}

View file

@ -2,6 +2,8 @@ package cmd
import (
"fmt"
"runtime"
"time"
"github.com/Masterminds/semver/v3"
"github.com/Snider/Mining/pkg/mining"
@ -89,7 +91,18 @@ func updateDoctorCache() error {
}
allDetails = append(allDetails, details)
}
return saveResultsToCache(allDetails)
// Create the SystemInfo struct that the /info endpoint expects
systemInfo := &mining.SystemInfo{
Timestamp: time.Now(),
OS: runtime.GOOS,
Architecture: runtime.GOARCH,
GoVersion: runtime.Version(),
AvailableCPUCores: runtime.NumCPU(),
InstalledMinersInfo: allDetails,
}
return saveResultsToCache(systemInfo)
}
func init() {

View file

@ -3,7 +3,6 @@ package cmd
import (
"fmt"
"github.com/Snider/Mining/pkg/mining"
"github.com/spf13/cobra"
)
@ -11,27 +10,21 @@ import (
var uninstallCmd = &cobra.Command{
Use: "uninstall [miner_type]",
Short: "Uninstall a miner",
Long: `Remove all files associated with a specific miner.`,
Long: `Stops the miner if it is running, removes all associated files, and updates the configuration.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
minerType := args[0]
manager := getManager() // Assuming getManager() provides the singleton manager instance
var miner mining.Miner
switch minerType {
case "xmrig":
miner = mining.NewXMRigMiner()
default:
return fmt.Errorf("unknown miner type: %s", minerType)
}
fmt.Printf("Uninstalling %s...\n", miner.GetName())
if err := miner.Uninstall(); err != nil {
fmt.Printf("Uninstalling %s...\n", minerType)
if err := manager.UninstallMiner(minerType); err != nil {
return fmt.Errorf("failed to uninstall miner: %w", err)
}
fmt.Printf("%s uninstalled successfully.\n", miner.GetName())
fmt.Printf("%s uninstalled successfully.\n", minerType)
// Update the cache after a successful uninstallation
// The doctor cache is implicitly updated by the manager's actions,
// but an explicit cache update can still be beneficial.
fmt.Println("Updating installation cache...")
if err := updateDoctorCache(); err != nil {
fmt.Printf("Warning: failed to update doctor cache: %v\n", err)

View file

@ -29,10 +29,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/mining.InstallationDetails"
}
"$ref": "#/definitions/mining.SystemInfo"
}
}
}
@ -569,6 +566,10 @@ const docTemplate = `{
"mining.InstallationDetails": {
"type": "object",
"properties": {
"config_path": {
"description": "Add path to the miner-specific config",
"type": "string"
},
"is_installed": {
"type": "boolean"
},

View file

@ -23,10 +23,7 @@
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/mining.InstallationDetails"
}
"$ref": "#/definitions/mining.SystemInfo"
}
}
}
@ -563,6 +560,10 @@
"mining.InstallationDetails": {
"type": "object",
"properties": {
"config_path": {
"description": "Add path to the miner-specific config",
"type": "string"
},
"is_installed": {
"type": "boolean"
},

View file

@ -154,6 +154,9 @@ definitions:
type: object
mining.InstallationDetails:
properties:
config_path:
description: Add path to the miner-specific config
type: string
is_installed:
type: boolean
miner_binary:
@ -244,9 +247,7 @@ paths:
"200":
description: OK
schema:
items:
$ref: '#/definitions/mining.InstallationDetails'
type: array
$ref: '#/definitions/mining.SystemInfo'
summary: Check miner installations
tags:
- system

View file

@ -0,0 +1,71 @@
package mining
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/adrg/xdg"
)
// MinerAutostartConfig represents the configuration for a single miner's autostart settings.
type MinerAutostartConfig struct {
MinerType string `json:"minerType"`
Autostart bool `json:"autostart"`
Config *Config `json:"config,omitempty"` // Store the last used config
}
// MinersConfig represents the overall configuration for all miners, including autostart settings.
type MinersConfig struct {
Miners []MinerAutostartConfig `json:"miners"`
}
// GetMinersConfigPath returns the path to the miners configuration file.
func GetMinersConfigPath() (string, error) {
return xdg.ConfigFile("lethean-desktop/miners/config.json")
}
// LoadMinersConfig loads the miners configuration from the file system.
func LoadMinersConfig() (*MinersConfig, error) {
configPath, err := GetMinersConfigPath()
if err != nil {
return nil, fmt.Errorf("could not determine miners config path: %w", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return &MinersConfig{Miners: []MinerAutostartConfig{}}, nil // Return empty config if file doesn't exist
}
return nil, fmt.Errorf("failed to read miners config file: %w", err)
}
var cfg MinersConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal miners config: %w", err)
}
return &cfg, nil
}
// SaveMinersConfig saves the miners configuration to the file system.
func SaveMinersConfig(cfg *MinersConfig) error {
configPath, err := GetMinersConfigPath()
if err != nil {
return fmt.Errorf("could not determine miners config path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal miners config: %w", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("failed to write miners config file: %w", err)
}
return nil
}

View file

@ -10,10 +10,19 @@ import (
"time"
)
// ManagerInterface defines the contract for a miner manager.
type ManagerInterface interface {
StartMiner(minerType string, config *Config) (Miner, error)
StopMiner(name string) error
GetMiner(name string) (Miner, error)
ListMiners() []Miner
ListAvailableMiners() []AvailableMiner
GetMinerHashrateHistory(name string) ([]HashratePoint, error)
UninstallMiner(minerType string) error
Stop()
}
// Manager handles the lifecycle and operations of multiple miners.
// It provides a centralized way to start, stop, and manage different miner
// instances, while also collecting and exposing their performance data.
// The Manager is safe for concurrent use.
type Manager struct {
miners map[string]Miner
mu sync.RWMutex
@ -23,24 +32,80 @@ type Manager struct {
var _ ManagerInterface = (*Manager)(nil)
// NewManager creates a new miner manager.
// NewManager creates a new miner manager and autostarts miners based on config.
func NewManager() *Manager {
m := &Manager{
miners: make(map[string]Miner),
stopChan: make(chan struct{}),
waitGroup: sync.WaitGroup{},
}
m.syncMinersConfig() // Ensure config file is populated
m.autostartMiners()
m.startStatsCollection()
return m
}
// syncMinersConfig ensures the miners.json config file has entries for all available miners.
func (m *Manager) syncMinersConfig() {
cfg, err := LoadMinersConfig()
if err != nil {
log.Printf("Warning: could not load miners config for sync: %v", err)
return
}
availableMiners := m.ListAvailableMiners()
configUpdated := false
for _, availableMiner := range availableMiners {
found := false
for _, configuredMiner := range cfg.Miners {
if strings.EqualFold(configuredMiner.MinerType, availableMiner.Name) {
found = true
break
}
}
if !found {
cfg.Miners = append(cfg.Miners, MinerAutostartConfig{
MinerType: availableMiner.Name,
Autostart: false,
Config: nil, // No default config
})
configUpdated = true
log.Printf("Added default config for missing miner: %s", availableMiner.Name)
}
}
if configUpdated {
if err := SaveMinersConfig(cfg); err != nil {
log.Printf("Warning: failed to save updated miners config: %v", err)
}
}
}
// autostartMiners loads the miners config and starts any miners marked for autostart.
func (m *Manager) autostartMiners() {
cfg, err := LoadMinersConfig()
if err != nil {
log.Printf("Warning: could not load miners config for autostart: %v", err)
return
}
for _, minerCfg := range cfg.Miners {
if minerCfg.Autostart && minerCfg.Config != nil {
log.Printf("Autostarting miner: %s", minerCfg.MinerType)
if _, err := m.StartMiner(minerCfg.MinerType, minerCfg.Config); err != nil {
log.Printf("Failed to autostart miner %s: %v", minerCfg.MinerType, err)
}
}
}
}
// findAvailablePort finds an available TCP port on the local machine.
func findAvailablePort() (int, error) {
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
if err != nil {
return 0, err
}
l, err := net.ListenTCP("tcp", addr)
if err != nil {
return 0, err
@ -49,12 +114,11 @@ func findAvailablePort() (int, error) {
return l.Addr().(*net.TCPAddr).Port, nil
}
// StartMiner starts a new miner with the given configuration.
// StartMiner starts a new miner and saves its configuration.
func (m *Manager) StartMiner(minerType string, config *Config) (Miner, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Prevent nil pointer panic if request body is empty
if config == nil {
config = &Config{}
}
@ -99,12 +163,91 @@ func (m *Manager) StartMiner(minerType string, config *Config) (Miner, error) {
m.miners[instanceName] = miner
if err := m.updateMinerConfig(minerType, true, config); err != nil {
log.Printf("Warning: failed to save miner config for autostart: %v", err)
}
logMessage := fmt.Sprintf("CryptoCurrency Miner started: %s (Binary: %s)", miner.GetName(), miner.GetBinaryPath())
logToSyslog(logMessage)
return miner, nil
}
// UninstallMiner stops, uninstalls, and removes a miner's configuration.
func (m *Manager) UninstallMiner(minerType string) error {
m.mu.Lock()
for name, runningMiner := range m.miners {
if rm, ok := runningMiner.(*XMRigMiner); ok && strings.EqualFold(rm.ExecutableName, minerType) {
if err := runningMiner.Stop(); err != nil {
log.Printf("Warning: failed to stop running miner %s during uninstall: %v", name, err)
}
delete(m.miners, name)
}
if rm, ok := runningMiner.(*TTMiner); ok && strings.EqualFold(rm.ExecutableName, minerType) {
if err := runningMiner.Stop(); err != nil {
log.Printf("Warning: failed to stop running miner %s during uninstall: %v", name, err)
}
delete(m.miners, name)
}
}
m.mu.Unlock()
var miner Miner
switch strings.ToLower(minerType) {
case "xmrig":
miner = NewXMRigMiner()
default:
return fmt.Errorf("unsupported miner type: %s", minerType)
}
if err := miner.Uninstall(); err != nil {
return fmt.Errorf("failed to uninstall miner files: %w", err)
}
cfg, err := LoadMinersConfig()
if err != nil {
return fmt.Errorf("failed to load miners config to update uninstall status: %w", err)
}
var updatedMiners []MinerAutostartConfig
for _, minerCfg := range cfg.Miners {
if !strings.EqualFold(minerCfg.MinerType, minerType) {
updatedMiners = append(updatedMiners, minerCfg)
}
}
cfg.Miners = updatedMiners
return SaveMinersConfig(cfg)
}
// updateMinerConfig saves the autostart and last-used config for a miner.
func (m *Manager) updateMinerConfig(minerType string, autostart bool, config *Config) error {
cfg, err := LoadMinersConfig()
if err != nil {
return err
}
found := false
for i, minerCfg := range cfg.Miners {
if strings.EqualFold(minerCfg.MinerType, minerType) {
cfg.Miners[i].Autostart = autostart
cfg.Miners[i].Config = config
found = true
break
}
}
if !found {
cfg.Miners = append(cfg.Miners, MinerAutostartConfig{
MinerType: minerType,
Autostart: autostart,
Config: config,
})
}
return SaveMinersConfig(cfg)
}
// StopMiner stops a running miner.
func (m *Manager) StopMiner(name string) error {
m.mu.Lock()
@ -138,7 +281,6 @@ func (m *Manager) StopMiner(name string) error {
func (m *Manager) GetMiner(name string) (Miner, error) {
m.mu.RLock()
defer m.mu.RUnlock()
miner, exists := m.miners[name]
if !exists {
return nil, fmt.Errorf("miner not found: %s", name)
@ -150,7 +292,6 @@ func (m *Manager) GetMiner(name string) (Miner, error) {
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)
@ -215,7 +356,6 @@ func (m *Manager) collectMinerStats() {
func (m *Manager) GetMinerHashrateHistory(name string) ([]HashratePoint, error) {
m.mu.RLock()
defer m.mu.RUnlock()
miner, exists := m.miners[name]
if !exists {
return nil, fmt.Errorf("miner not found: %s", name)

View file

@ -1,38 +1,5 @@
package mining
// ManagerInterface defines the interface for a miner manager.
// This interface abstracts the core functionalities of a miner manager,
// allowing for different implementations to be used interchangeably. It provides
// a standard way to manage the lifecycle of miners and retrieve their data.
type ManagerInterface interface {
// StartMiner starts a new miner with the given configuration.
// It takes the miner type and a configuration object, and returns the
// created miner instance or an error if the miner could not be started.
StartMiner(minerType string, config *Config) (Miner, error)
// StopMiner stops a running miner.
// It takes the name of the miner to be stopped and returns an error if the
// miner could not be stopped.
StopMiner(name string) error
// GetMiner retrieves a running miner by its name.
// It returns the miner instance or an error if the miner is not found.
GetMiner(name string) (Miner, error)
// ListMiners returns a slice of all running miners.
ListMiners() []Miner
// ListAvailableMiners returns a list of available miners that can be started.
// This provides a way to discover the types of miners supported by the manager.
ListAvailableMiners() []AvailableMiner
// GetMinerHashrateHistory returns the hashrate history for a specific miner.
// It takes the name of the miner and returns a slice of hashrate points
// or an error if the miner is not found.
GetMinerHashrateHistory(name string) ([]HashratePoint, error)
// Stop stops the manager and its background goroutines.
// It should be called when the manager is no longer needed to ensure a
// graceful shutdown of any background processes.
Stop()
}
// This file is intentionally left with only a package declaration
// to resolve a redeclaration error. The ManagerInterface is defined
// in manager.go.

View file

@ -15,6 +15,7 @@ import (
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -125,7 +126,47 @@ func (b *BaseMiner) InstallFromURL(url string) error {
return nil
}
// parseVersion parses a version string (e.g., "6.24.0") into a slice of integers for comparison.
func parseVersion(v string) []int {
parts := strings.Split(v, ".")
intParts := make([]int, len(parts))
for i, p := range parts {
val, err := strconv.Atoi(p)
if err != nil {
return []int{0} // Malformed version, treat as very old
}
intParts[i] = val
}
return intParts
}
// compareVersions compares two version slices. Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal.
func compareVersions(v1, v2 []int) int {
minLen := len(v1)
if len(v2) < minLen {
minLen = len(v2)
}
for i := 0; i < minLen; i++ {
if v1[i] > v2[i] {
return 1
}
if v1[i] < v2[i] {
return -1
}
}
if len(v1) > len(v2) {
return 1
}
if len(v1) < len(v2) {
return -1
}
return 0
}
// findMinerBinary searches for the miner's executable file.
// It returns the absolute path to the executable if found, prioritizing the highest versioned installation.
func (b *BaseMiner) findMinerBinary() (string, error) {
executableName := b.ExecutableName
if runtime.GOOS == "windows" {
@ -135,35 +176,40 @@ func (b *BaseMiner) findMinerBinary() (string, error) {
baseInstallPath := b.GetPath()
searchedPaths := []string{}
if _, err := os.Stat(baseInstallPath); err == nil {
var latestModTime time.Time
var latestDir string
var highestVersion []int
var highestVersionDir string
// 1. Check the standard installation directory first
if _, err := os.Stat(baseInstallPath); err == nil {
dirs, err := os.ReadDir(baseInstallPath)
if err == nil {
for _, d := range dirs {
if d.IsDir() && strings.HasPrefix(d.Name(), b.ExecutableName+"-") {
// Extract version string, e.g., "xmrig-6.24.0" -> "6.24.0"
versionStr := strings.TrimPrefix(d.Name(), b.ExecutableName+"-")
currentVersion := parseVersion(versionStr)
if highestVersionDir == "" || compareVersions(currentVersion, highestVersion) > 0 {
highestVersion = currentVersion
highestVersionDir = d.Name()
}
versionedPath := filepath.Join(baseInstallPath, d.Name())
fullPath := filepath.Join(versionedPath, executableName)
searchedPaths = append(searchedPaths, fullPath)
info, err := d.Info()
if err == nil && info.ModTime().After(latestModTime) {
latestModTime = info.ModTime()
latestDir = d.Name()
}
}
}
}
if latestDir != "" {
fullPath := filepath.Join(baseInstallPath, latestDir, executableName)
if highestVersionDir != "" {
fullPath := filepath.Join(baseInstallPath, highestVersionDir, executableName)
if _, err := os.Stat(fullPath); err == nil {
log.Printf("Found miner binary at standard path: %s", fullPath)
log.Printf("Found miner binary at highest versioned path: %s", fullPath)
return fullPath, nil
}
}
}
// 2. Fallback to searching the system PATH
path, err := exec.LookPath(executableName)
if err == nil {
absPath, err := filepath.Abs(path)
@ -174,6 +220,7 @@ func (b *BaseMiner) findMinerBinary() (string, error) {
return absPath, nil
}
// If not found, return a detailed error
return "", fmt.Errorf("miner executable '%s' not found. Searched in: %s and system PATH", executableName, strings.Join(searchedPaths, ", "))
}

View file

@ -5,20 +5,13 @@ import (
)
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
LowResolutionInterval = 1 * time.Minute
LowResHistoryRetention = 24 * time.Hour
)
// Miner defines the standard interface for a cryptocurrency miner.
// This interface abstracts the core functionalities of a miner, such as installation,
// starting, stopping, and statistics retrieval, allowing for different miner
// implementations to be used interchangeably.
type Miner interface {
Install() error
Uninstall() error
@ -41,6 +34,7 @@ type InstallationDetails struct {
Version string `json:"version"`
Path string `json:"path"`
MinerBinary string `json:"miner_binary"`
ConfigPath string `json:"config_path,omitempty"` // Add path to the miner-specific config
}
// SystemInfo provides general system and miner installation information.

View file

@ -0,0 +1,12 @@
package mining
// MiningProfile represents a saved configuration for a specific mining setup.
// This allows users to define and switch between different miners, pools,
// and wallets without re-entering information.
type MiningProfile struct {
Name string `json:"name"` // A user-defined name for the profile, e.g., "My XMR Rig"
Pool string `json:"pool"` // The mining pool address
Wallet string `json:"wallet"` // The wallet address
Miner string `json:"miner"` // The type of miner, e.g., "xmrig"
// This can be expanded later to include the full *Config for advanced options
}

View file

@ -14,6 +14,7 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/Snider/Mining/docs"
"github.com/adrg/xdg"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/shirou/gopsutil/v4/mem" // Import mem for memory stats
@ -37,23 +38,21 @@ type Service struct {
// NewService creates a new mining service
func NewService(manager ManagerInterface, listenAddr string, displayAddr string, swaggerNamespace string) *Service {
apiBasePath := "/" + strings.Trim(swaggerNamespace, "/")
swaggerUIPath := apiBasePath + "/swagger" // Serve Swagger UI under a distinct sub-path
swaggerUIPath := apiBasePath + "/swagger"
// Dynamically configure Swagger at runtime
docs.SwaggerInfo.Title = "Mining Module API"
docs.SwaggerInfo.Version = "1.0"
docs.SwaggerInfo.Host = displayAddr // Use the displayable address for Swagger UI
docs.SwaggerInfo.Host = displayAddr
docs.SwaggerInfo.BasePath = apiBasePath
// Use a unique instance name to avoid conflicts in a multi-module environment
instanceName := "swagger_" + strings.ReplaceAll(strings.Trim(swaggerNamespace, "/"), "/", "_")
swag.Register(instanceName, docs.SwaggerInfo)
return &Service{
Manager: manager,
Server: &http.Server{
Addr: listenAddr, // Server listens on this address
Addr: listenAddr,
},
DisplayAddr: displayAddr, // Store displayable address for messages
DisplayAddr: displayAddr,
SwaggerInstanceName: instanceName,
APIBasePath: apiBasePath,
SwaggerUIPath: swaggerUIPath,
@ -74,9 +73,7 @@ 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,10 +85,9 @@ func (s *Service) ServiceStartup(ctx context.Context) error {
}
func (s *Service) setupRoutes() {
// All API routes are now relative to the service's APIBasePath
apiGroup := s.Router.Group(s.APIBasePath)
{
apiGroup.GET("/info", s.handleGetInfo) // New GET endpoint for cached info
apiGroup.GET("/info", s.handleGetInfo)
apiGroup.POST("/doctor", s.handleDoctor)
apiGroup.POST("/update", s.handleUpdateCheck)
@ -104,15 +100,12 @@ 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
minersGroup.GET("/:miner_name/hashrate-history", s.handleGetMinerHashrateHistory)
}
}
// New route to serve the custom HTML element bundle
// This path now points to the output of the Angular project within the 'ui' directory
s.Router.StaticFile("/component/mining-dashboard.js", "./ui/dist/ui/mbe-mining-dashboard.js")
// Register Swagger UI route under a distinct sub-path to avoid conflicts
swaggerURL := ginSwagger.URL(fmt.Sprintf("http://%s%s/doc.json", s.DisplayAddr, s.SwaggerUIPath))
s.Router.GET(s.SwaggerUIPath+"/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, swaggerURL, ginSwagger.InstanceName(s.SwaggerInstanceName)))
}
@ -126,74 +119,40 @@ func (s *Service) setupRoutes() {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /info [get]
func (s *Service) handleGetInfo(c *gin.Context) {
systemInfo := SystemInfo{
Timestamp: time.Now(),
OS: runtime.GOOS,
Architecture: runtime.GOARCH,
GoVersion: runtime.Version(),
AvailableCPUCores: runtime.NumCPU(),
}
// Get total system RAM
vMem, err := mem.VirtualMemory()
configDir, err := xdg.ConfigFile("lethean-desktop/miners")
if err != nil {
log.Printf("Warning: Failed to get virtual memory info: %v", err)
systemInfo.TotalSystemRAMGB = 0.0 // Default to 0 on error
} else {
// Convert bytes to GB
systemInfo.TotalSystemRAMGB = float64(vMem.Total) / (1024 * 1024 * 1024)
}
homeDir, err := os.UserHomeDir()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get home directory"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get config directory"})
return
}
signpostPath := filepath.Join(homeDir, ".installed-miners")
configPathBytes, err := os.ReadFile(signpostPath)
if err != nil {
// If signpost or cache doesn't exist, return SystemInfo with empty miner details
systemInfo.InstalledMinersInfo = []*InstallationDetails{}
c.JSON(http.StatusOK, systemInfo)
return
}
configPath := string(configPathBytes)
configPath := filepath.Join(configDir, "config.json")
cacheBytes, err := os.ReadFile(configPath)
if err != nil {
// If cache file is missing, return SystemInfo with empty miner details
systemInfo.InstalledMinersInfo = []*InstallationDetails{}
c.JSON(http.StatusOK, systemInfo)
if os.IsNotExist(err) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cache file not found, run setup"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read cache file"})
return
}
var cachedDetails []*InstallationDetails
if err := json.Unmarshal(cacheBytes, &cachedDetails); err != nil {
var systemInfo SystemInfo
if err := json.Unmarshal(cacheBytes, &systemInfo); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not parse cache file"})
return
}
// Filter for only installed miners
var installedOnly []*InstallationDetails
for _, detail := range cachedDetails {
if detail.IsInstalled {
installedOnly = append(installedOnly, detail)
}
systemInfo.Timestamp = time.Now()
vMem, err := mem.VirtualMemory()
if err == nil {
systemInfo.TotalSystemRAMGB = float64(vMem.Total) / (1024 * 1024 * 1024)
}
systemInfo.InstalledMinersInfo = installedOnly
c.JSON(http.StatusOK, systemInfo)
}
// handleDoctor godoc
// @Summary Check miner installations
// @Description Performs a live check on all available miners to verify their installation status, version, and path.
// @Tags system
// @Produce json
// @Success 200 {array} InstallationDetails
// @Router /doctor [post]
func (s *Service) handleDoctor(c *gin.Context) {
// updateInstallationCache performs a live check and updates the cache file.
func (s *Service) updateInstallationCache() (*SystemInfo, error) {
var allDetails []*InstallationDetails
for _, availableMiner := range s.Manager.ListAvailableMiners() {
var miner Miner
@ -203,14 +162,54 @@ func (s *Service) handleDoctor(c *gin.Context) {
default:
continue
}
details, err := miner.CheckInstallation()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check " + miner.GetName(), "details": err.Error()})
return
}
details, _ := miner.CheckInstallation()
allDetails = append(allDetails, details)
}
c.JSON(http.StatusOK, allDetails)
systemInfo := &SystemInfo{
Timestamp: time.Now(),
OS: runtime.GOOS,
Architecture: runtime.GOARCH,
GoVersion: runtime.Version(),
AvailableCPUCores: runtime.NumCPU(),
InstalledMinersInfo: allDetails,
}
configDir, err := xdg.ConfigFile("lethean-desktop/miners")
if err != nil {
return nil, fmt.Errorf("could not get config directory: %w", err)
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, fmt.Errorf("could not create config directory: %w", err)
}
configPath := filepath.Join(configDir, "config.json")
data, err := json.MarshalIndent(systemInfo, "", " ")
if err != nil {
return nil, fmt.Errorf("could not marshal cache data: %w", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return nil, fmt.Errorf("could not write cache file: %w", err)
}
return systemInfo, nil
}
// handleDoctor godoc
// @Summary Check miner installations
// @Description Performs a live check on all available miners to verify their installation status, version, and path.
// @Tags system
// @Produce json
// @Success 200 {object} SystemInfo
// @Router /doctor [post]
func (s *Service) handleDoctor(c *gin.Context) {
systemInfo, err := s.updateInstallationCache()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cache", "details": err.Error()})
return
}
c.JSON(http.StatusOK, systemInfo)
}
// handleUpdateCheck godoc
@ -274,19 +273,14 @@ func (s *Service) handleUpdateCheck(c *gin.Context) {
// @Router /miners/{miner_type}/uninstall [delete]
func (s *Service) handleUninstallMiner(c *gin.Context) {
minerType := c.Param("miner_name")
var miner Miner
switch minerType {
case "xmrig":
miner = NewXMRigMiner()
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown miner type"})
return
}
if err := miner.Uninstall(); err != nil {
if err := s.Manager.UninstallMiner(minerType); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": miner.GetName() + " uninstalled successfully."})
if _, err := s.updateInstallationCache(); err != nil {
log.Printf("Warning: failed to update cache after uninstall: %v", err)
}
c.JSON(http.StatusOK, gin.H{"status": minerType + " uninstalled successfully."})
}
// handleListMiners godoc
@ -337,6 +331,10 @@ func (s *Service) handleInstallMiner(c *gin.Context) {
return
}
if _, err := s.updateInstallationCache(); err != nil {
log.Printf("Warning: failed to update cache after install: %v", err)
}
details, err := miner.CheckInstallation()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify installation", "details": err.Error()})

View file

@ -1,13 +1,19 @@
package mining
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/adrg/xdg"
)
// XMRigMiner represents an XMRig miner, embedding the BaseMiner for common functionality.
@ -38,6 +44,20 @@ func NewXMRigMiner() *XMRigMiner {
}
}
// getXMRigConfigPath returns the platform-specific path for the xmrig.json file.
func getXMRigConfigPath() (string, error) {
path, err := xdg.ConfigFile("lethean-desktop/xmrig.json")
if err != nil {
// Fallback for non-XDG environments or when XDG variables are not set
homeDir, homeErr := os.UserHomeDir()
if homeErr != nil {
return "", homeErr
}
return filepath.Join(homeDir, ".config", "lethean-desktop", "xmrig.json"), nil
}
return path, nil
}
// GetLatestVersion fetches the latest version of XMRig from the GitHub API.
func (m *XMRigMiner) GetLatestVersion() (string, error) {
resp, err := httpClient.Get("https://api.github.com/repos/xmrig/xmrig/releases/latest")
@ -92,3 +112,55 @@ func (m *XMRigMiner) Install() error {
return nil
}
// Uninstall removes all files related to the XMRig miner, including its specific config file.
func (m *XMRigMiner) Uninstall() error {
// Remove the specific xmrig.json config file using the centralized helper
configPath, err := getXMRigConfigPath()
if err == nil {
os.Remove(configPath) // Ignore error if it doesn't exist
}
// Call the base uninstall method to remove the installation directory
return m.BaseMiner.Uninstall()
}
// CheckInstallation verifies if the XMRig miner is installed correctly.
func (m *XMRigMiner) CheckInstallation() (*InstallationDetails, error) {
binaryPath, err := m.findMinerBinary()
if err != nil {
return &InstallationDetails{IsInstalled: false}, err
}
m.MinerBinary = binaryPath
m.Path = filepath.Dir(binaryPath)
cmd := exec.Command(binaryPath, "--version")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
m.Version = "Unknown (could not run executable)"
} else {
fields := strings.Fields(out.String())
if len(fields) >= 2 {
m.Version = fields[1]
} else {
m.Version = "Unknown (could not parse version)"
}
}
// Get the config path using the helper
configPath, err := getXMRigConfigPath()
if err != nil {
// Log the error but don't fail CheckInstallation if config path can't be determined
configPath = "Error: Could not determine config path"
}
return &InstallationDetails{
IsInstalled: true,
MinerBinary: m.MinerBinary,
Path: m.Path,
Version: m.Version,
ConfigPath: configPath, // Include the config path
}, nil
}

View file

@ -9,8 +9,6 @@ import (
"os/exec"
"path/filepath"
"strings"
"github.com/adrg/xdg"
)
// Start launches the XMRig miner with the specified configuration.
@ -40,7 +38,8 @@ func (m *XMRigMiner) Start(config *Config) error {
return err
}
} else {
configPath, err := xdg.ConfigFile("lethean-desktop/xmrig.json")
// Use the centralized helper to get the config path
configPath, err := getXMRigConfigPath()
if err != nil {
return fmt.Errorf("could not determine config file path: %w", err)
}
@ -105,13 +104,10 @@ func addCliArgs(config *Config, args *[]string) {
// createConfig creates a JSON configuration file for the XMRig miner.
func (m *XMRigMiner) createConfig(config *Config) error {
configPath, err := xdg.ConfigFile("lethean-desktop/xmrig.json")
// Use the centralized helper to get the config path
configPath, err := getXMRigConfigPath()
if err != nil {
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configPath = filepath.Join(homeDir, ".config", "lethean-desktop", "xmrig.json")
return err
}
m.ConfigPath = configPath

View file

@ -32,9 +32,10 @@
width: 100%;
}
.card-error {
--base-color: var(--wa-color-red-200);
border-color: var(--wa-color-red-400);
.header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.miner-list {
@ -48,8 +49,8 @@
flex-direction: column;
gap: 1rem;
padding: 0.75rem;
border-radius: var(--wa-border-radius-medium);
background-color: var(--wa-color-neutral-50);
border-radius: 0.25rem;
border: 1px solid #e0e0e0;
}
.miner-item {
@ -74,10 +75,43 @@
display: flex;
flex-direction: column;
gap: 1rem;
border-top: 1px solid var(--wa-color-neutral-200);
border-top: 1px solid #e0e0e0;
padding-top: 1rem;
}
.admin-panel {
padding: 1rem;
border-top: 1px solid #e0e0e0;
margin-top: 1rem;
border-radius: 0.25rem;
border: 1px solid #e0e0e0;
}
.admin-title {
margin-top: 0;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.path-list ul {
list-style: none;
padding: 0.5rem;
margin: 0;
font-family: monospace;
border-radius: 0.25rem;
border: 1px solid #e0e0e0;
}
.path-list li {
padding: 0.25rem 0;
}
.button-spinner {
font-size: 1em; /* Make spinner same size as button text */
margin: 0;
}
wa-button {
min-width: auto;
}

View file

@ -1,7 +1,7 @@
<div class="mining-dashboard">
<!-- Initial Loading State -->
@if (systemInfo === null) {
@if (systemInfo === null && !needsSetup) {
<div class="centered-container">
<wa-card class="card-overview">
<div slot="header">Loading...</div>
@ -10,30 +10,53 @@
</div>
}
<!-- API Not Available State -->
@if (systemInfo !== null && !apiAvailable) {
<!-- Setup Wizard: Shown when needsSetup is true -->
@if (needsSetup) {
<div class="centered-container">
<wa-card class="card-overview">
<div slot="header">
<wa-icon name="cloud-off" style="font-size: 2rem;"></wa-icon>
API Not Available
<wa-icon name="wand-magic-sparkles" style="font-size: 2rem;"></wa-icon>
Welcome! Let's Get Started
</div>
<div>
<p>{{ error }}</p>
<p>Please ensure the mining API server is running and accessible.</p>
<p>To begin, please install a miner from the list below.</p>
<h4>Available Miners</h4>
<div class="miner-list">
@for (miner of manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
<div slot="footer">
<wa-button (click)="checkSystemState()">
<wa-icon name="arrow-clockwise" slot="prefix"></wa-icon>
Retry
Refresh Status
</wa-button>
</div>
</wa-card>
</div>
}
<!-- Main Content -->
@if (systemInfo !== null && apiAvailable) {
<!-- Main Content: API is Available and initial load is complete -->
@if (systemInfo !== null && apiAvailable && !needsSetup) {
<div>
@if (error) {
<wa-card class="card-error">
@ -47,33 +70,73 @@
<wa-card class="card-overview">
<div slot="header" class="card-header">
<div>
<div class="header-title">
<wa-icon name="cpu" style="font-size: 1.5rem;"></wa-icon>
Mining Control
</div>
<wa-button variant="neutral" appearance="plain" (click)="toggleAdminPanel()">
<wa-icon name="gear" label="Admin Panel"></wa-icon>
</wa-button>
</div>
@if (installedMiners.length === 0) {
<div class="miner-list">
<div class="miner-item">
<span>No miners installed.</span>
<!-- Admin Panel -->
@if (showAdminPanel) {
<div class="admin-panel">
<h3 class="admin-title">Admin Panel</h3>
<h4>Manage Miners</h4>
<div class="miner-list">
@for (miner of manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
@if (miner.is_installed) {
<wa-button
variant="danger"
size="small"
[disabled]="actionInProgress === 'uninstall-' + miner.name"
(click)="uninstallMiner(miner.name)">
@if (actionInProgress === 'uninstall-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="trash" slot="prefix"></wa-icon>
Uninstall
}
</wa-button>
} @else {
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
}
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
<h4>Antivirus Whitelist Paths</h4>
<div class="path-list">
<p>To prevent antivirus software from interfering, please add the following paths to your exclusion list:</p>
<ul>
@for (path of whitelistPaths; track path) {
<li><code>{{ path }}</code></li>
}
</ul>
</div>
@for (miner of availableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
<wa-button variant="success" (click)="installMiner(miner.name)">
<wa-icon name="download" slot="prefix"></wa-icon>
Install
</wa-button>
</div>
} @empty {
<div class="miner-item">
<span>Checking for available miners...</span>
</div>
}
</div>
}
<!-- Miner Control List -->
@if (installedMiners.length > 0) {
<div class="miner-list">
@for (miner of installedMiners; track miner.path) {
@ -88,17 +151,34 @@
</span>
@if (isMinerRunning(miner)) {
<wa-button variant="danger" (click)="stopMiner(miner)">
<wa-icon name="stop-circle" slot="prefix"></wa-icon>
Stop
<wa-button
variant="danger"
[disabled]="actionInProgress === 'stop-' + miner.type"
(click)="stopMiner(miner)">
@if (actionInProgress === 'stop-' + miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="stop-circle" slot="prefix"></wa-icon>
Stop
}
</wa-button>
} @else {
<div class="start-buttons">
<wa-button variant="secondary" (click)="startMiner(miner, true)">
<wa-icon name="play-circle" slot="prefix"></wa-icon>
Start Last Config
<wa-button
variant="secondary"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="startMiner(miner, true)">
@if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="play-circle" slot="prefix"></wa-icon>
Start Last Config
}
</wa-button>
<wa-button variant="primary" (click)="toggleStartOptions(miner.type)">
<wa-button
variant="primary"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="toggleStartOptions(miner.type)">
<wa-icon name="gear" slot="prefix"></wa-icon>
New Config
</wa-button>
@ -110,9 +190,16 @@
<div class="start-options">
<wa-input label="Pool Address" [(ngModel)]="poolAddress" name="poolAddress"></wa-input>
<wa-input label="Wallet Address" [(ngModel)]="walletAddress" name="walletAddress"></wa-input>
<wa-button variant="success" (click)="startMiner(miner)">
<wa-icon name="rocket-launch" slot="prefix"></wa-icon>
Confirm & Start
<wa-button
variant="success"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="startMiner(miner)">
@if (actionInProgress === 'start-' + miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="rocket-launch" slot="prefix"></wa-icon>
Confirm & Start
}
</wa-button>
</div>
}

View file

@ -8,7 +8,7 @@ import {
import { HttpClient, HttpClientModule, HttpErrorResponse } from '@angular/common/http';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { of } from 'rxjs';
import { of, forkJoin } from 'rxjs';
import { switchMap, catchError, map } from 'rxjs/operators';
// Import Web Awesome components
@ -20,6 +20,21 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/input/input.js';
// Define interfaces for our data structures
interface InstallationDetails {
is_installed: boolean;
version: string;
path: string;
miner_binary: string;
config_path?: string;
type?: string;
}
interface AvailableMiner {
name: string;
description: string;
}
@Component({
selector: 'mde-mining-dashboard',
standalone: true,
@ -33,13 +48,17 @@ export class MiningDashboardElementComponent implements OnInit {
apiBaseUrl: string = 'http://localhost:9090/api/v1/mining';
// State management
needsSetup: boolean = false;
apiAvailable: boolean = true;
error: string | null = null;
showAdminPanel: boolean = false;
actionInProgress: string | null = null; // To track which miner action is running
systemInfo: any = null;
availableMiners: any[] = [];
manageableMiners: any[] = [];
runningMiners: any[] = [];
installedMiners: any[] = [];
installedMiners: InstallationDetails[] = [];
whitelistPaths: string[] = [];
// Form inputs
poolAddress: string = 'pool.hashvault.pro:80';
@ -55,10 +74,8 @@ export class MiningDashboardElementComponent implements OnInit {
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
if (err.error && err.error.error) {
// Handles { "error": "..." } from the backend
this.error = `${defaultMessage}: ${err.error.error}`;
} else if (typeof err.error === 'string' && err.error.length < 200) {
// Handles plain text errors
this.error = `${defaultMessage}: ${err.error}`;
} else {
this.error = `${defaultMessage}. Please check the console for details.`;
@ -67,43 +84,66 @@ export class MiningDashboardElementComponent implements OnInit {
checkSystemState() {
this.error = null;
this.http.get<any>(`${this.apiBaseUrl}/info`).pipe(
switchMap(info => {
forkJoin({
available: this.http.get<AvailableMiner[]>(`${this.apiBaseUrl}/miners/available`),
info: this.http.get<any>(`${this.apiBaseUrl}/info`)
}).pipe(
switchMap(({ available, info }) => {
this.apiAvailable = true;
this.systemInfo = info;
this.installedMiners = (info.installed_miners_info || [])
.filter((m: any) => m.is_installed)
.map((m: any) => ({ ...m, type: this.getMinerType(m) }));
const trulyInstalledMiners = (info.installed_miners_info || []).filter((m: InstallationDetails) => m.is_installed);
if (this.installedMiners.length === 0) {
this.fetchAvailableMiners();
if (trulyInstalledMiners.length === 0) {
this.needsSetup = true;
this.manageableMiners = available.map(availMiner => ({ ...availMiner, is_installed: false }));
this.installedMiners = [];
this.runningMiners = [];
return of(null);
}
this.needsSetup = false;
const installedMap = new Map<string, InstallationDetails>(
(info.installed_miners_info || []).map((m: InstallationDetails) => [this.getMinerType(m), m])
);
this.manageableMiners = available.map(availMiner => ({
...availMiner,
is_installed: installedMap.get(availMiner.name)?.is_installed ?? false,
}));
this.installedMiners = trulyInstalledMiners.map((m: InstallationDetails) => ({ ...m, type: this.getMinerType(m) }));
this.updateWhitelistPaths();
return this.fetchRunningMiners();
}),
catchError(err => {
this.apiAvailable = false;
this.error = 'Failed to connect to the mining API.';
if (err.status === 500) {
this.needsSetup = true;
this.fetchAvailableMinersForWizard();
} else {
this.apiAvailable = false;
this.error = 'Failed to connect to the mining API.';
}
this.systemInfo = {};
this.installedMiners = [];
this.runningMiners = [];
console.error('API not available:', err);
console.error('API not available or needs setup:', err);
return of(null);
})
).subscribe();
}
fetchAvailableMiners(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/miners/available`).subscribe({
next: miners => { this.availableMiners = miners; },
error: err => { this.handleError(err, 'Could not fetch available miners'); }
fetchAvailableMinersForWizard(): void {
this.http.get<AvailableMiner[]>(`${this.apiBaseUrl}/miners/available`).subscribe({
next: miners => { this.manageableMiners = miners.map(m => ({...m, is_installed: false})); },
error: err => { this.handleError(err, 'Could not fetch available miners for setup'); }
});
}
fetchRunningMiners() {
return this.http.get<any[]>(`${this.apiBaseUrl}/miners`).pipe(
map(miners => { this.runningMiners = miners; }),
map(miners => { this.runningMiners = miners; this.updateWhitelistPaths(); }),
catchError(err => {
this.handleError(err, 'Could not fetch running miners');
this.runningMiners = [];
@ -112,22 +152,55 @@ export class MiningDashboardElementComponent implements OnInit {
);
}
private performAction(action: any) {
action.subscribe({
private updateWhitelistPaths() {
const paths = new Set<string>();
this.installedMiners.forEach(miner => {
if (miner.miner_binary) paths.add(miner.miner_binary);
if (miner.config_path) paths.add(miner.config_path);
});
this.runningMiners.forEach(miner => {
if (miner.configPath) paths.add(miner.configPath);
});
this.whitelistPaths = Array.from(paths);
}
installMiner(minerType: string): void {
this.actionInProgress = `install-${minerType}`;
this.error = null;
this.http.post(`${this.apiBaseUrl}/miners/${minerType}/install`, {}).subscribe({
next: () => {
setTimeout(() => this.checkSystemState(), 1000);
setTimeout(() => {
this.checkSystemState();
this.actionInProgress = null;
}, 1000);
},
error: (err: HttpErrorResponse) => {
this.handleError(err, 'Action failed');
this.handleError(err, `Failed to install ${minerType}`);
this.actionInProgress = null;
}
});
}
installMiner(minerType: string): void {
this.performAction(this.http.post(`${this.apiBaseUrl}/miners/${minerType}/install`, {}));
uninstallMiner(minerType: string): void {
this.actionInProgress = `uninstall-${minerType}`;
this.error = null;
this.http.delete(`${this.apiBaseUrl}/miners/${minerType}/uninstall`).subscribe({
next: () => {
setTimeout(() => {
this.checkSystemState();
this.actionInProgress = null;
}, 1000);
},
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to uninstall ${minerType}`);
this.actionInProgress = null;
}
});
}
startMiner(miner: any, useLastConfig: boolean = false): void {
this.actionInProgress = `start-${miner.type}`;
this.error = null;
let config = {};
if (!useLastConfig) {
config = {
@ -137,7 +210,18 @@ export class MiningDashboardElementComponent implements OnInit {
hugePages: true,
};
}
this.performAction(this.http.post(`${this.apiBaseUrl}/miners/${miner.type}`, config));
this.http.post(`${this.apiBaseUrl}/miners/${miner.type}`, config).subscribe({
next: () => {
setTimeout(() => {
this.checkSystemState();
this.actionInProgress = null;
}, 1000);
},
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to start ${miner.type}`);
this.actionInProgress = null;
}
});
this.showStartOptionsFor = null;
}
@ -147,10 +231,28 @@ export class MiningDashboardElementComponent implements OnInit {
this.error = "Cannot stop a miner that is not running.";
return;
}
this.performAction(this.http.delete(`${this.apiBaseUrl}/miners/${runningInstance.name}`));
this.actionInProgress = `stop-${miner.type}`;
this.error = null;
this.http.delete(`${this.apiBaseUrl}/miners/${runningInstance.name}`).subscribe({
next: () => {
setTimeout(() => {
this.checkSystemState();
this.actionInProgress = null;
}, 1000);
},
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to stop ${runningInstance.name}`);
this.actionInProgress = null;
}
});
}
toggleStartOptions(minerType: string): void {
toggleAdminPanel(): void {
this.showAdminPanel = !this.showAdminPanel;
}
toggleStartOptions(minerType: string | undefined): void {
if (!minerType) return;
this.showStartOptionsFor = this.showStartOptionsFor === minerType ? null : minerType;
}