From 816f860b731b931d3b2eb53a476780d99f2016eb Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 7 Dec 2025 16:26:18 +0000 Subject: [PATCH] feat: Enhance mining configuration management and API documentation --- cmd/mining/cmd/doctor.go | 29 +++--- cmd/mining/cmd/install.go | 15 ++- cmd/mining/cmd/uninstall.go | 21 ++-- docs/docs.go | 9 +- docs/swagger.json | 9 +- docs/swagger.yaml | 7 +- pkg/mining/config_manager.go | 71 ++++++++++++++ pkg/mining/manager.go | 160 +++++++++++++++++++++++++++++-- pkg/mining/manager_interface.go | 39 +------- pkg/mining/miner.go | 69 ++++++++++--- pkg/mining/mining.go | 12 +-- pkg/mining/mining_profile.go | 12 +++ pkg/mining/service.go | 158 +++++++++++++++--------------- pkg/mining/xmrig.go | 72 ++++++++++++++ pkg/mining/xmrig_start.go | 14 +-- ui/src/app/app.css | 46 +++++++-- ui/src/app/app.html | 165 ++++++++++++++++++++++++-------- ui/src/app/app.ts | 160 +++++++++++++++++++++++++------ 18 files changed, 796 insertions(+), 272 deletions(-) create mode 100644 pkg/mining/config_manager.go create mode 100644 pkg/mining/mining_profile.go diff --git a/cmd/mining/cmd/doctor.go b/cmd/mining/cmd/doctor.go index 0097484..2d27bbe 100644 --- a/cmd/mining/cmd/doctor.go +++ b/cmd/mining/cmd/doctor.go @@ -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) } diff --git a/cmd/mining/cmd/install.go b/cmd/mining/cmd/install.go index fe768ba..83afb96 100644 --- a/cmd/mining/cmd/install.go +++ b/cmd/mining/cmd/install.go @@ -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() { diff --git a/cmd/mining/cmd/uninstall.go b/cmd/mining/cmd/uninstall.go index d8f3e69..b01a162 100644 --- a/cmd/mining/cmd/uninstall.go +++ b/cmd/mining/cmd/uninstall.go @@ -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) diff --git a/docs/docs.go b/docs/docs.go index 2eda190..bf25ac1 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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" }, diff --git a/docs/swagger.json b/docs/swagger.json index fd1efa3..cb296aa 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 29bd590..d06c079 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/pkg/mining/config_manager.go b/pkg/mining/config_manager.go new file mode 100644 index 0000000..73267bf --- /dev/null +++ b/pkg/mining/config_manager.go @@ -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 +} diff --git a/pkg/mining/manager.go b/pkg/mining/manager.go index 3a5a94f..a57005c 100644 --- a/pkg/mining/manager.go +++ b/pkg/mining/manager.go @@ -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) diff --git a/pkg/mining/manager_interface.go b/pkg/mining/manager_interface.go index 46a17c4..ffb8394 100644 --- a/pkg/mining/manager_interface.go +++ b/pkg/mining/manager_interface.go @@ -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. diff --git a/pkg/mining/miner.go b/pkg/mining/miner.go index c228752..d2268f3 100644 --- a/pkg/mining/miner.go +++ b/pkg/mining/miner.go @@ -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, ", ")) } diff --git a/pkg/mining/mining.go b/pkg/mining/mining.go index 423e336..e14947c 100644 --- a/pkg/mining/mining.go +++ b/pkg/mining/mining.go @@ -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. diff --git a/pkg/mining/mining_profile.go b/pkg/mining/mining_profile.go new file mode 100644 index 0000000..c95bdbb --- /dev/null +++ b/pkg/mining/mining_profile.go @@ -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 +} diff --git a/pkg/mining/service.go b/pkg/mining/service.go index 9690ef6..aa54dde 100644 --- a/pkg/mining/service.go +++ b/pkg/mining/service.go @@ -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()}) diff --git a/pkg/mining/xmrig.go b/pkg/mining/xmrig.go index 7b71719..26191e1 100644 --- a/pkg/mining/xmrig.go +++ b/pkg/mining/xmrig.go @@ -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 +} diff --git a/pkg/mining/xmrig_start.go b/pkg/mining/xmrig_start.go index 037f63c..2f6993b 100644 --- a/pkg/mining/xmrig_start.go +++ b/pkg/mining/xmrig_start.go @@ -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 diff --git a/ui/src/app/app.css b/ui/src/app/app.css index fe9e4ea..a00e46e 100644 --- a/ui/src/app/app.css +++ b/ui/src/app/app.css @@ -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; } diff --git a/ui/src/app/app.html b/ui/src/app/app.html index a3606db..b5627e0 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -1,7 +1,7 @@
- @if (systemInfo === null) { + @if (systemInfo === null && !needsSetup) {
Loading...
@@ -10,30 +10,53 @@
} - - @if (systemInfo !== null && !apiAvailable) { + + @if (needsSetup) {
- - API Not Available + + Welcome! Let's Get Started
-
-

{{ error }}

-

Please ensure the mining API server is running and accessible.

+

To begin, please install a miner from the list below.

+ +

Available Miners

+
+ @for (miner of manageableMiners; track miner.name) { +
+ {{ miner.name }} + + @if (actionInProgress === 'install-' + miner.name) { + + } @else { + + Install + } + +
+ } @empty { +
+ Could not load available miners. +
+ }
+
- Retry + Refresh Status
} - - @if (systemInfo !== null && apiAvailable) { + + @if (systemInfo !== null && apiAvailable && !needsSetup) {
@if (error) { @@ -47,33 +70,73 @@
-
+
Mining Control
+ + +
- @if (installedMiners.length === 0) { -
-
- No miners installed. + + @if (showAdminPanel) { +
+

Admin Panel

+ +

Manage Miners

+
+ @for (miner of manageableMiners; track miner.name) { +
+ {{ miner.name }} + @if (miner.is_installed) { + + @if (actionInProgress === 'uninstall-' + miner.name) { + + } @else { + + Uninstall + } + + } @else { + + @if (actionInProgress === 'install-' + miner.name) { + + } @else { + + Install + } + + } +
+ } @empty { +
+ Could not load available miners. +
+ } +
+ +

Antivirus Whitelist Paths

+
+

To prevent antivirus software from interfering, please add the following paths to your exclusion list:

+
    + @for (path of whitelistPaths; track path) { +
  • {{ path }}
  • + } +
- @for (miner of availableMiners; track miner.name) { -
- {{ miner.name }} - - - Install - -
- } @empty { -
- Checking for available miners... -
- }
} + @if (installedMiners.length > 0) {
@for (miner of installedMiners; track miner.path) { @@ -88,17 +151,34 @@ @if (isMinerRunning(miner)) { - - - Stop + + @if (actionInProgress === 'stop-' + miner.type) { + + } @else { + + Stop + } } @else {
- - - Start Last Config + + @if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) { + + } @else { + + Start Last Config + } - + New Config @@ -110,9 +190,16 @@
- - - Confirm & Start + + @if (actionInProgress === 'start-' + miner.type) { + + } @else { + + Confirm & Start + }
} diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 98a1d9e..fcac68b 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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(`${this.apiBaseUrl}/info`).pipe( - switchMap(info => { + forkJoin({ + available: this.http.get(`${this.apiBaseUrl}/miners/available`), + info: this.http.get(`${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( + (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(`${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(`${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(`${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(); + 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; }